From d94ad85f06cb4b083d0ec9e8ac483f22b4cd043a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 7 May 2026 18:10:28 -0400 Subject: [PATCH 1/5] Add bm users/whoami/access-key commands + table flag consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full Business Manager Data API user administration to the CLI: - bm users list/get/search/update/delete (OCAPI /users, /user_search) - bm whoami (OCAPI /users/this — defaults to user-auth) - bm access-key get/create/set/delete (OCAPI /users/{login}/access_key/{scope}; optional [LOGIN] defaults to whoami; --scope is an enum with WEBDAV_AND_STUDIO as the default) A new SDK module @salesforce/b2c-tooling-sdk/operations/bm-users wraps the underlying endpoints. Endpoints whose OCAPI documentation states "a valid user is required" (whoami + access-key) extend a shared BmUserAuthCommand base which defaults the auth-method priority to ['implicit'] so a fresh shell triggers browser login rather than failing the API call with UserNotAvailableException. Also reworks tabular output across the CLI for consistency: - New SDK helpers columnFlagsFor() / selectColumns() replace 22 copies of an identical getSelectedColumns() helper. printFieldsBlock() does the same for *Get-style label/value detail blocks (5 commands). - Adds --columns / --extended to ~30 list and search commands that previously had no column-customization (bm roles list, webdav ls, cap list, code list, content list, docs search, job search, logs list, sites list, slas client list, every mrt/* list command, plus several setup and scaffold commands). webdav ls --extended now exposes the previously-hidden modified and contentType columns. - Renames --confirm to --force on the new bm/users delete commands to match the dominant codebase convention. Skills + docs: new b2c-cli:b2c-bm-users-roles skill and a rewritten docs/cli/bm.md page cover the four bm command groups and the user-auth defaulting. The b2c-am skill now defers to the new BM skill. --- .changeset/bm-docs-overhaul.md | 5 + .changeset/bm-users-commands.md | 6 + .changeset/bm-users-roles-skill.md | 5 + .changeset/cli-table-consistency.md | 8 + docs/.vitepress/config.mts | 2 +- docs/cli/bm-roles.md | 309 ----------- docs/cli/bm.md | 482 ++++++++++++++++++ docs/cli/index.md | 2 +- docs/typedoc.json | 1 + packages/b2c-cli/package.json | 6 + .../b2c-cli/src/commands/am/clients/get.ts | 164 ++---- .../b2c-cli/src/commands/am/clients/list.ts | 36 +- packages/b2c-cli/src/commands/am/orgs/list.ts | 23 +- packages/b2c-cli/src/commands/am/roles/get.ts | 61 +-- .../b2c-cli/src/commands/am/roles/list.ts | 38 +- .../b2c-cli/src/commands/am/users/list.ts | 38 +- .../src/commands/bm/access-key/create.ts | 83 +++ .../src/commands/bm/access-key/delete.ts | 99 ++++ .../b2c-cli/src/commands/bm/access-key/get.ts | 75 +++ .../b2c-cli/src/commands/bm/access-key/set.ts | 80 +++ packages/b2c-cli/src/commands/bm/roles/get.ts | 65 +-- .../b2c-cli/src/commands/bm/roles/list.ts | 15 +- .../b2c-cli/src/commands/bm/users/delete.ts | 75 +++ packages/b2c-cli/src/commands/bm/users/get.ts | 67 +++ .../b2c-cli/src/commands/bm/users/list.ts | 111 ++++ .../b2c-cli/src/commands/bm/users/search.ts | 149 ++++++ .../b2c-cli/src/commands/bm/users/update.ts | 96 ++++ packages/b2c-cli/src/commands/bm/whoami.ts | 46 ++ packages/b2c-cli/src/commands/cap/list.ts | 30 +- packages/b2c-cli/src/commands/code/list.ts | 18 +- packages/b2c-cli/src/commands/content/list.ts | 13 +- packages/b2c-cli/src/commands/docs/search.ts | 15 +- .../src/commands/ecdn/certificates/list.ts | 40 +- .../src/commands/ecdn/logpush/jobs/list.ts | 37 +- .../b2c-cli/src/commands/ecdn/mtls/list.ts | 37 +- .../ecdn/page-shield/notifications/list.ts | 37 +- .../ecdn/page-shield/policies/list.ts | 37 +- .../commands/ecdn/page-shield/scripts/list.ts | 37 +- .../src/commands/ecdn/waf/groups/list.ts | 13 +- .../commands/ecdn/waf/managed-rules/list.ts | 36 +- .../src/commands/ecdn/waf/rules/list.ts | 36 +- .../src/commands/ecdn/waf/rulesets/list.ts | 37 +- .../b2c-cli/src/commands/ecdn/zones/list.ts | 43 +- packages/b2c-cli/src/commands/job/search.ts | 13 +- packages/b2c-cli/src/commands/logs/list.ts | 13 +- .../src/commands/mrt/bundle/history.ts | 16 +- .../b2c-cli/src/commands/mrt/bundle/list.ts | 16 +- .../commands/mrt/env/access-control/list.ts | 16 +- packages/b2c-cli/src/commands/mrt/env/b2c.ts | 13 +- packages/b2c-cli/src/commands/mrt/env/list.ts | 16 +- .../src/commands/mrt/env/redirect/list.ts | 16 +- .../b2c-cli/src/commands/mrt/env/var/list.ts | 13 +- packages/b2c-cli/src/commands/mrt/org/b2c.ts | 13 +- packages/b2c-cli/src/commands/mrt/org/list.ts | 16 +- .../b2c-cli/src/commands/mrt/project/list.ts | 16 +- .../src/commands/mrt/project/member/list.ts | 16 +- .../commands/mrt/project/notification/list.ts | 16 +- .../src/commands/mrt/user/email-prefs.ts | 13 +- .../b2c-cli/src/commands/mrt/user/profile.ts | 13 +- .../src/commands/sandbox/alias/list.ts | 61 ++- .../src/commands/sandbox/clone/list.ts | 36 +- packages/b2c-cli/src/commands/sandbox/list.ts | 47 +- .../src/commands/sandbox/operations/list.ts | 41 +- .../b2c-cli/src/commands/scaffold/list.ts | 43 +- .../b2c-cli/src/commands/scaffold/search.ts | 11 +- .../src/commands/scapi/custom/status.ts | 47 +- .../src/commands/scapi/replications/list.ts | 41 +- .../src/commands/scapi/schemas/list.ts | 42 +- .../src/commands/setup/instance/list.ts | 13 +- packages/b2c-cli/src/commands/setup/skills.ts | 16 +- packages/b2c-cli/src/commands/sites/list.ts | 21 +- .../b2c-cli/src/commands/slas/client/list.ts | 7 +- packages/b2c-cli/src/commands/webdav/ls.ts | 21 +- packages/b2c-cli/src/utils/am/user-display.ts | 94 ++-- .../b2c-cli/src/utils/bm/resolve-login.ts | 21 + .../b2c-cli/src/utils/bm/user-auth-command.ts | 39 ++ .../test/commands/am/roles/list.test.ts | 27 - .../test/commands/am/users/list.test.ts | 37 -- .../commands/ecdn/certificates/list.test.ts | 23 - .../commands/ecdn/logpush/jobs/list.test.ts | 19 - .../test/commands/ecdn/mtls/list.test.ts | 22 - .../page-shield/notifications/list.test.ts | 13 - .../ecdn/page-shield/policies/list.test.ts | 13 - .../ecdn/page-shield/scripts/list.test.ts | 19 - .../test/commands/ecdn/zones/list.test.ts | 34 -- .../test/commands/sandbox/list.test.ts | 40 -- .../commands/scapi/replications/list.test.ts | 40 -- packages/b2c-tooling-sdk/package.json | 11 + packages/b2c-tooling-sdk/src/cli/columns.ts | 172 +++++++ packages/b2c-tooling-sdk/src/cli/details.ts | 122 +++++ packages/b2c-tooling-sdk/src/cli/index.ts | 4 + .../b2c-tooling-sdk/src/cli/oauth-command.ts | 9 +- .../src/operations/bm-users/index.ts | 75 +++ .../src/operations/bm-users/users.ts | 370 ++++++++++++++ skills/b2c-cli/skills/b2c-am/SKILL.md | 55 +- .../skills/b2c-bm-users-roles/SKILL.md | 195 +++++++ 96 files changed, 3054 insertions(+), 1695 deletions(-) create mode 100644 .changeset/bm-docs-overhaul.md create mode 100644 .changeset/bm-users-commands.md create mode 100644 .changeset/bm-users-roles-skill.md create mode 100644 .changeset/cli-table-consistency.md delete mode 100644 docs/cli/bm-roles.md create mode 100644 docs/cli/bm.md create mode 100644 packages/b2c-cli/src/commands/bm/access-key/create.ts create mode 100644 packages/b2c-cli/src/commands/bm/access-key/delete.ts create mode 100644 packages/b2c-cli/src/commands/bm/access-key/get.ts create mode 100644 packages/b2c-cli/src/commands/bm/access-key/set.ts create mode 100644 packages/b2c-cli/src/commands/bm/users/delete.ts create mode 100644 packages/b2c-cli/src/commands/bm/users/get.ts create mode 100644 packages/b2c-cli/src/commands/bm/users/list.ts create mode 100644 packages/b2c-cli/src/commands/bm/users/search.ts create mode 100644 packages/b2c-cli/src/commands/bm/users/update.ts create mode 100644 packages/b2c-cli/src/commands/bm/whoami.ts create mode 100644 packages/b2c-cli/src/utils/bm/resolve-login.ts create mode 100644 packages/b2c-cli/src/utils/bm/user-auth-command.ts create mode 100644 packages/b2c-tooling-sdk/src/cli/columns.ts create mode 100644 packages/b2c-tooling-sdk/src/cli/details.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/index.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/users.ts create mode 100644 skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md diff --git a/.changeset/bm-docs-overhaul.md b/.changeset/bm-docs-overhaul.md new file mode 100644 index 000000000..05807d67c --- /dev/null +++ b/.changeset/bm-docs-overhaul.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-dx-docs': minor +--- + +Replaced the BM Roles docs page with a comprehensive Business Manager reference covering all `b2c bm` commands — `bm roles` (list/get/create/delete/grant/revoke + permissions), `bm users` (list/get/search/update/delete), `bm whoami`, and `bm access-key` (get/create/set/delete). The new page documents the user-auth requirement on whoami and access-key endpoints, the access-key scope enum, and common workflows like rotating your own WebDAV password. diff --git a/.changeset/bm-users-commands.md b/.changeset/bm-users-commands.md new file mode 100644 index 000000000..8538f76fa --- /dev/null +++ b/.changeset/bm-users-commands.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Added `b2c bm users` command topic for managing instance-level Business Manager users via the OCAPI Data API: `list`, `get`, `search`, `whoami`, `update`, and `delete`. Also added `b2c bm users access-keys` (`get`, `create`, `set`, `delete`) for provisioning and rotating WebDAV/OCAPI/SCAPI access keys for externally-managed (AM/SSO) users. The SDK now exposes a matching `@salesforce/b2c-tooling-sdk/operations/bm-users` module. diff --git a/.changeset/bm-users-roles-skill.md b/.changeset/bm-users-roles-skill.md new file mode 100644 index 000000000..2ac620a1d --- /dev/null +++ b/.changeset/bm-users-roles-skill.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-agent-plugins': minor +--- + +Added a new `b2c-bm-users-roles` skill covering all `b2c bm` instance commands — `bm roles`, `bm users`, `bm whoami`, and `bm access-key`. The existing `b2c-am` skill now defers to it for Business Manager content and stays focused on Account Manager (cross-instance) administration. diff --git a/.changeset/cli-table-consistency.md b/.changeset/cli-table-consistency.md new file mode 100644 index 000000000..ba29d9b39 --- /dev/null +++ b/.changeset/cli-table-consistency.md @@ -0,0 +1,8 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Added `--columns` and `--extended` flags to all list and search commands for consistent column selection across the CLI. Roughly 30 commands that previously had no column-customization support — including `bm roles list`, `webdav ls`, `cap list`, `code list`, `content list`, `docs search`, `job search`, `logs list`, `sites list`, `slas client list`, all `mrt` list commands, plus several `setup` and `scaffold` commands — now accept `-c id,name,...` to pick columns and `-x` to include extended fields (e.g. `webdav ls --extended` exposes the previously-hidden `modified` and `contentType` columns). + +The SDK now exposes shared `columnFlagsFor()` / `selectColumns()` helpers (replacing 22 duplicated implementations) and a `printFieldsBlock()` helper for rendering "label / value" detail blocks. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a6f763698..a824a161c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -102,7 +102,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: 'Business Manager', link: '/cli/bm'}, {text: 'CIP', link: '/cli/cip'}, {text: 'CAP (Commerce Apps)', link: '/cli/cap'}, {text: 'Code', link: '/cli/code'}, diff --git a/docs/cli/bm-roles.md b/docs/cli/bm-roles.md deleted file mode 100644 index 1b5abd727..000000000 --- a/docs/cli/bm-roles.md +++ /dev/null @@ -1,309 +0,0 @@ ---- -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/cli/bm.md b/docs/cli/bm.md new file mode 100644 index 000000000..ca40ad230 --- /dev/null +++ b/docs/cli/bm.md @@ -0,0 +1,482 @@ +--- +description: Commands for administering Business Manager resources on a B2C Commerce instance — access roles, users, access keys, and identity (whoami). +--- + +# 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. + +## Authentication + +Most BM commands accept either client credentials or browser-based user authentication. A handful require a *real BM user identity* — the CLI defaults those to user-auth automatically. + +| Command group | Default auth | Why | +|---|---|---| +| `b2c bm roles ...` | client-credentials → jwt → implicit | OCAPI permissions for `/roles` | +| `b2c bm users {list,get,search,update,delete}` | client-credentials → jwt → implicit | OCAPI permissions for `/users` | +| `b2c bm whoami` | **implicit (browser)** | OCAPI `/users/this` requires the token to resolve to a BM user | +| `b2c bm access-key ...` | **implicit (browser)** | OCAPI access-key endpoints require "a valid user" plus the `Manage_Users_Access_Keys` functional permission | + +Override the default with `--auth-methods client-credentials` (or `--client-secret` flags) when your service-client setup is configured to issue user-bearing tokens. + +For complete setup instructions see the [Authentication Guide](/guide/authentication). + +### Required OCAPI Permissions + +| Resource | Methods | +|----------|---------| +| `/roles` | GET | +| `/roles/*` | GET, PUT, DELETE | +| `/roles/*/users` | GET, PUT, DELETE | +| `/users` | GET | +| `/users/*` | GET, PATCH, DELETE | +| `/users/this` | GET | +| `/users/*/access_key/*` | GET, PUT, PATCH, DELETE | +| `/user_search` | POST | + +Access-key writes additionally require the `Manage_Users_Access_Keys` functional permission on the BM user account. + +--- + +## Roles + +`b2c bm roles` — manage instance-level Business Manager access roles, user assignments, and role permissions. + +### b2c bm roles list + +List all access roles on an instance. + +```bash +b2c bm roles list [--count ] [--start ] [--columns ] [--extended] +``` + +| Flag | Description | +|------|-------------| +| `--count`, `-n` | Number of roles to return (default 25) | +| `--start` | Start index for pagination (default 0) | +| `--columns`, `-c` | Comma-separated columns to display | +| `--extended`, `-x` | Show all columns including extended fields | + +```bash +b2c bm roles list +b2c bm roles list --server my-sandbox.demandware.net +b2c bm roles list --extended --json +``` + +### b2c bm roles get + +Get details of a specific access role. + +```bash +b2c bm roles get [--expand ...] +``` + +| Argument | Description | +|----------|-------------| +| `role` | Role ID (e.g. `Administrator`) | + +| Flag | Description | +|------|-------------| +| `--expand`, `-e` | Expansions to apply (`users`, `permissions`). Repeatable. | + +```bash +b2c bm roles get Administrator +b2c bm roles get Administrator --expand users +``` + +### b2c bm roles create + +Create a new custom access role. + +```bash +b2c bm roles create [--description ] +``` + +| Argument | Description | +|----------|-------------| +| `role` | Role ID to create | + +| Flag | Description | +|------|-------------| +| `--description`, `-d` | Description for the role | + +```bash +b2c bm roles create ContentEditor --description "Role for content editors" +``` + +::: warning +Reserved role IDs (`Support`, `Business Support`) cannot be created. +::: + +### b2c bm roles delete + +Delete a custom access role. + +```bash +b2c bm roles delete +``` + +```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. + +```bash +b2c bm roles grant --role +``` + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to grant (required) | + +```bash +b2c bm roles grant user@example.com --role Administrator +``` + +### b2c bm roles revoke + +Unassign a user from an access role. + +```bash +b2c bm roles revoke --role +``` + +```bash +b2c bm roles revoke user@example.com --role Administrator +``` + +### b2c bm roles permissions get + +Get permissions for an access role. + +```bash +b2c bm roles permissions get [--output ] +``` + +| Flag | Description | +|------|-------------| +| `--output`, `-o` | Write full permissions JSON to a file for editing | + +```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. + +```bash +b2c bm roles permissions set --file +``` + +| Flag | Description | +|------|-------------| +| `--file`, `-f` | JSON file containing permissions (`role_permissions` schema) (required) | + +```bash +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 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"}] + } +} +``` + +--- + +## Users + +`b2c bm users` — query and manage instance-level Business Manager users via the OCAPI `/users` resource. + +::: tip +Most production instances use SSO with Account Manager; creating *local* BM users via the Data API is rejected with `LocalUserCreationException`. These commands focus on read/search/lifecycle for AM-managed users plus access-key administration. +::: + +### b2c bm users list + +List all users on the instance. + +```bash +b2c bm users list [--count ] [--start ] [--columns ] [--extended] +``` + +| Flag | Description | +|------|-------------| +| `--count`, `-n` | Number of users to return (default 25) | +| `--start` | Start index for pagination (default 0) | +| `--columns`, `-c` | Comma-separated columns to display | +| `--extended`, `-x` | Include extended columns (`lastLogin`, `externalId`) | + +```bash +b2c bm users list +b2c bm users list --count 100 +b2c bm users list --extended --json +b2c bm users list --columns login,email,lastLogin +``` + +### b2c bm users get + +Get details of one user by login. + +```bash +b2c bm users get +``` + +```bash +b2c bm users get user@example.com +b2c bm users get user@example.com --json +``` + +### b2c bm users search + +Search users by login, email, name, lock state, or disabled state. Searchable fields per the Data API: `login`, `email`, `first_name`, `last_name`, `external_id`, `last_login_date`, `is_locked`, `is_disabled`. + +```bash +b2c bm users search [--search-phrase ] [--login ] [--email ] \ + [--locked] [--disabled] [--sort-by ] [--sort-order asc|desc] \ + [--query ] [--count ] [--start ] [--columns ] [--extended] +``` + +| Flag | Description | +|------|-------------| +| `--search-phrase` | Free-text phrase searched across login/email/first_name/last_name | +| `--login` | Match a specific login | +| `--email` | Match a specific email | +| `--locked` / `--no-locked` | Match locked / unlocked users | +| `--disabled` / `--no-disabled` | Match disabled / enabled users | +| `--sort-by` | Sort field (e.g. `last_login_date`) | +| `--sort-order` | `asc` or `desc` | +| `--query` | Raw OCAPI query JSON (overrides convenience flags) | + +```bash +b2c bm users search --search-phrase smith +b2c bm users search --locked --sort-by last_login_date --sort-order desc +b2c bm users search --query '{"text_query":{"fields":["login"],"search_phrase":"foo"}}' +``` + +### b2c bm users update + +Update non-identity user fields. The `locked` flag and `password` cannot be updated through this command — those are governed by Account Manager / SSO. + +```bash +b2c bm users update [--disabled | --no-disabled] [--first-name ] \ + [--last-name ] [--email ] [--external-id ] \ + [--preferred-ui-locale ] [--preferred-data-locale ] +``` + +```bash +b2c bm users update user@example.com --disabled +b2c bm users update user@example.com --no-disabled --preferred-ui-locale en_US +b2c bm users update user@example.com --first-name Jane --last-name Doe +``` + +### b2c bm users delete + +Remove a user from the instance. Prompts for confirmation by default. + +```bash +b2c bm users delete [--force] +``` + +| Flag | Description | +|------|-------------| +| `--force` | Skip the confirmation prompt | + +```bash +b2c bm users delete user@example.com +b2c bm users delete user@example.com --force --json +``` + +--- + +## Whoami + +`b2c bm whoami` — show the Business Manager user the current OAuth token resolves to. Calls `GET /users/this`. + +```bash +b2c bm whoami [--json] +``` + +Useful for verifying which BM identity is in use after `b2c auth login`, or for sanity-checking that a token actually carries a user claim. + +```bash +b2c bm whoami +b2c bm whoami --json | jq -r '.login' +``` + +::: tip +This command defaults to browser-based user-auth — a fresh shell triggers `b2c auth login`. The saved session is reused across commands until it expires. +::: + +--- + +## Access Keys + +`b2c bm access-key` — manage WebDAV / OCAPI / Storefront access keys for SSO-managed (externally managed) Business Manager users. These keys let users authenticate to non-OAuth surfaces using their BM identity. + +`[LOGIN]` is **optional** on every access-key command — when omitted, the CLI calls `bm whoami` first and operates on your own user. + +### Scopes + +| Scope | Used for | +|---|---| +| `WEBDAV_AND_STUDIO` (default) | WebDAV uploads (cartridge sync, IMPEX), Studio access | +| `AGENT_USER_AND_OCAPI` | Customer Service Center (CSC) and OCAPI Basic auth | +| `STOREFRONT` | Storefront diagnostic / agent login passwords | + +### b2c bm access-key get + +Get the current state of an access key. + +```bash +b2c bm access-key get [] [--scope ] +``` + +| Argument | Description | +|----------|-------------| +| `[login]` | User login (email). Defaults to the currently authenticated user. | + +| Flag | Description | +|------|-------------| +| `--scope` | One of `WEBDAV_AND_STUDIO` (default), `AGENT_USER_AND_OCAPI`, `STOREFRONT` | + +```bash +b2c bm access-key get +b2c bm access-key get --scope STOREFRONT +b2c bm access-key get user@example.com --scope AGENT_USER_AND_OCAPI +``` + +### b2c bm access-key create + +Create or rotate an access key. The secret value is returned **only once**, at creation — record it immediately. + +```bash +b2c bm access-key create [] [--scope ] +``` + +```bash +b2c bm access-key create +b2c bm access-key create --scope STOREFRONT +b2c bm access-key create --json | jq -r '.access_key' +``` + +::: warning +The previous key for the same scope is removed automatically when a new one is created. The new `access_key` value is only returned in the response of `create` — subsequent `get` calls do not return it. +::: + +### b2c bm access-key set + +Enable or disable an existing access key. + +```bash +b2c bm access-key set [] [--scope ] (--enabled | --no-enabled) +``` + +| Flag | Description | +|------|-------------| +| `--enabled` / `--no-enabled` | Enable or disable the key (required) | + +```bash +b2c bm access-key set --enabled +b2c bm access-key set --no-enabled +b2c bm access-key set user@example.com --scope STOREFRONT --enabled +``` + +### b2c bm access-key delete + +Delete an access key. Prompts for confirmation by default. + +```bash +b2c bm access-key delete [] [--scope ] [--force] +``` + +| Flag | Description | +|------|-------------| +| `--force` | Skip the confirmation prompt | + +```bash +b2c bm access-key delete +b2c bm access-key delete --scope STOREFRONT --force +b2c bm access-key delete user@example.com --scope WEBDAV_AND_STUDIO +``` + +--- + +## Common Workflows + +### Rotate my own WebDAV password + +```bash +b2c bm access-key create +# record the printed access_key — use it as the new password for WebDAV/IMPEX +``` + +### Audit role assignments + +```bash +b2c bm roles get Administrator --expand users --json | jq '.users[].login' +``` + +### Find inactive users + +```bash +b2c bm users search --sort-by last_login_date --sort-order asc --json +``` + +### Provision a custom role and assign one user + +```bash +b2c bm roles create MyEditor --description "Content editors" +b2c bm roles permissions get MyEditor --output role.json +# ...edit role.json... +b2c bm roles permissions set MyEditor --file role.json +b2c bm roles grant editor@example.com --role MyEditor +``` + +### Cycle access keys for a user (admin) + +```bash +# disable temporarily +b2c bm access-key set user@example.com --scope WEBDAV_AND_STUDIO --no-enabled + +# rotate +b2c bm access-key create user@example.com --scope WEBDAV_AND_STUDIO +``` diff --git a/docs/cli/index.md b/docs/cli/index.md index b6bcfdc77..279974d16 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -69,7 +69,7 @@ Safety Mode operates at the HTTP layer and cannot be bypassed by command-line fl - [WebDAV Commands](./webdav) - File operations on instance WebDAV - [Logs Commands](./logs) - Tail and retrieve instance logs - [Content Commands](./content) - Export Page Designer content from a site -- [BM Roles Commands](./bm-roles) - Manage Business Manager roles and permissions +- [Business Manager Commands](./bm) - Manage instance-level BM roles, users, access keys, and identity ### Services diff --git a/docs/typedoc.json b/docs/typedoc.json index 153cec678..189a53bd6 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -18,6 +18,7 @@ "../packages/b2c-tooling-sdk/src/operations/users/index.ts", "../packages/b2c-tooling-sdk/src/operations/roles/index.ts", "../packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts", + "../packages/b2c-tooling-sdk/src/operations/bm-users/index.ts", "../packages/b2c-tooling-sdk/src/operations/sites/index.ts", "../packages/b2c-tooling-sdk/src/operations/orgs/index.ts", "../packages/b2c-tooling-sdk/src/slas/index.ts", diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index ed7815549..8765c7a57 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -108,6 +108,12 @@ "description": "Get and set permissions for Business Manager roles" } } + }, + "users": { + "description": "Manage and search instance-level Business Manager users" + }, + "access-key": { + "description": "Manage access keys for externally-managed Business Manager users" } } }, diff --git a/packages/b2c-cli/src/commands/am/clients/get.ts b/packages/b2c-cli/src/commands/am/clients/get.ts index 786bc8673..c9786643b 100644 --- a/packages/b2c-cli/src/commands/am/clients/get.ts +++ b/packages/b2c-cli/src/commands/am/clients/get.ts @@ -3,9 +3,8 @@ * 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 {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {Args, Flags} from '@oclif/core'; +import {AmCommand, printFieldsBlock, type DetailField, type DetailSection} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerApiClient, ApiClientExpandOption} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; @@ -94,12 +93,36 @@ export default class ClientGet extends AmCommand { return client; } - private printBasicFields(ui: ReturnType, c: AccountManagerApiClient): void { + private buildTenantFilterSection(c: AccountManagerApiClient): DetailSection | undefined { + const map = c.roleTenantFilterMap as Record | undefined; + const hasMap = map !== undefined && typeof map === 'object' && Object.keys(map).length > 0; + const filterStr = + typeof c.roleTenantFilter === 'string' && c.roleTenantFilter.length > 0 ? c.roleTenantFilter : undefined; + + if (!hasMap && !filterStr) { + return undefined; + } + + const fields: DetailField[] = []; + if (hasMap && map) { + for (const [roleId, filter] of Object.entries(map)) { + const filterValue = typeof filter === 'string' ? filter : JSON.stringify(filter); + fields.push([roleId, filterValue]); + } + } else if (filterStr) { + fields.push(['Filter', filterStr]); + } + + return {title: 'Role Tenant Filters', fields}; + } + + private printClientDetails(c: AccountManagerApiClient): void { const passwordModified = c.passwordModificationTimestamp !== null && c.passwordModificationTimestamp !== undefined ? new Date(c.passwordModificationTimestamp).toLocaleString() : undefined; - const fields: [string, string | undefined][] = [ + + const basicFields: DetailField[] = [ ['ID', c.id], ['Name', c.name], ['Description', c.description ?? undefined], @@ -111,128 +134,43 @@ export default class ClientGet extends AmCommand { ['Disabled', c.disabledTimestamp ? new Date(c.disabledTimestamp).toLocaleString() : undefined], ]; - 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]}); - } - } - } - - private printClientDetails(c: AccountManagerApiClient): void { - const ui = cliui({width: process.stdout.columns || 80}); - - ui.div({text: 'API Client Details', padding: [1, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - this.printBasicFields(ui, c); - this.printRedirectUrls(ui, c); - this.printScopes(ui, c); - this.printDefaultScopes(ui, c); - this.printOrganizations(ui, c); - this.printRoles(ui, c); - this.printRoleTenantFilters(ui, c); - this.printVersionControl(ui, c); - - ux.stdout(ui.toString()); - } + const sections: DetailSection[] = []; - private printDefaultScopes(ui: ReturnType, c: AccountManagerApiClient): void { - if (c.defaultScopes === undefined || c.defaultScopes.length === 0) { - return; + if (c.redirectUrls && c.redirectUrls.length > 0) { + sections.push({title: 'Redirect URLs', fields: [['URLs', c.redirectUrls.join(', ')]]}); } - ui.div({text: 'Default Scopes', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - ui.div( - {text: 'Default Scopes:', width: 25, padding: [0, 2, 0, 0]}, - {text: c.defaultScopes.join(', '), padding: [0, 0, 0, 0]}, - ); - } - - private printOrganizations(ui: ReturnType, c: AccountManagerApiClient): void { - if (c.organizations === undefined || c.organizations.length === 0) { - return; + if (c.scopes && c.scopes.length > 0) { + sections.push({title: 'Scopes', fields: [['Scopes', c.scopes.join(', ')]]}); } - ui.div({text: 'Organizations', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - const orgIds = c.organizations.map((o) => (typeof o === 'string' ? o : (o as {id?: string}).id || 'Unknown')); - ui.div( - {text: 'Organization IDs:', width: 25, padding: [0, 2, 0, 0]}, - {text: orgIds.join(', '), padding: [0, 0, 0, 0]}, - ); - } - - private printRedirectUrls(ui: ReturnType, c: AccountManagerApiClient): void { - if (c.redirectUrls === undefined || c.redirectUrls.length === 0) { - return; + if (c.defaultScopes && c.defaultScopes.length > 0) { + sections.push({title: 'Default Scopes', fields: [['Default Scopes', c.defaultScopes.join(', ')]]}); } - ui.div({text: 'Redirect URLs', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - ui.div({text: 'URLs:', width: 25, padding: [0, 2, 0, 0]}, {text: c.redirectUrls.join(', '), padding: [0, 0, 0, 0]}); - } - - private printRoles(ui: ReturnType, c: AccountManagerApiClient): void { - if (c.roles === undefined || c.roles.length === 0) { - return; - } - - ui.div({text: 'Roles', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - const roleNames = c.roles.map((r) => - typeof r === 'string' - ? r - : (r as {roleEnumName?: string; id?: string}).roleEnumName || (r as {id?: string}).id || 'Unknown', - ); - ui.div({text: 'Role IDs:', width: 25, padding: [0, 2, 0, 0]}, {text: roleNames.join(', '), padding: [0, 0, 0, 0]}); - } - - private printRoleTenantFilters(ui: ReturnType, c: AccountManagerApiClient): void { - const map = c.roleTenantFilterMap as Record | undefined; - const hasMap = map !== undefined && typeof map === 'object' && Object.keys(map).length > 0; - const filterStr = - typeof c.roleTenantFilter === 'string' && c.roleTenantFilter.length > 0 ? c.roleTenantFilter : undefined; - - if (!hasMap && !filterStr) { - return; + if (c.organizations && c.organizations.length > 0) { + const orgIds = c.organizations.map((o) => (typeof o === 'string' ? o : (o as {id?: string}).id || 'Unknown')); + sections.push({title: 'Organizations', fields: [['Organization IDs', orgIds.join(', ')]]}); } - ui.div({text: 'Role Tenant Filters', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - if (hasMap) { - for (const [roleId, filter] of Object.entries(map)) { - const filterValue = typeof filter === 'string' ? filter : JSON.stringify(filter); - ui.div({text: `${roleId}:`, width: 30, padding: [0, 2, 0, 0]}, {text: filterValue, padding: [0, 0, 0, 0]}); - } - } else if (filterStr) { - ui.div({text: 'Filter:', width: 25, padding: [0, 2, 0, 0]}, {text: filterStr, padding: [0, 0, 0, 0]}); + if (c.roles && c.roles.length > 0) { + const roleNames = c.roles.map((r) => + typeof r === 'string' + ? r + : (r as {roleEnumName?: string; id?: string}).roleEnumName || (r as {id?: string}).id || 'Unknown', + ); + sections.push({title: 'Roles', fields: [['Role IDs', roleNames.join(', ')]]}); } - } - private printScopes(ui: ReturnType, c: AccountManagerApiClient): void { - if (c.scopes === undefined || c.scopes.length === 0) { - return; + const tenantFilterSection = this.buildTenantFilterSection(c); + if (tenantFilterSection) { + sections.push(tenantFilterSection); } - ui.div({text: 'Scopes', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - ui.div({text: 'Scopes:', width: 25, padding: [0, 2, 0, 0]}, {text: c.scopes.join(', '), padding: [0, 0, 0, 0]}); - } - - private printVersionControl(ui: ReturnType, c: AccountManagerApiClient): void { - if (c.versionControl === undefined || c.versionControl.length === 0) { - return; + if (c.versionControl && c.versionControl.length > 0) { + sections.push({title: 'Version Control', fields: [['Identifiers', c.versionControl.join(', ')]]}); } - ui.div({text: 'Version Control', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - ui.div( - {text: 'Identifiers:', width: 25, padding: [0, 2, 0, 0]}, - {text: c.versionControl.join(', '), padding: [0, 0, 0, 0]}, - ); + printFieldsBlock('API Client Details', basicFields, {sections}); } } diff --git a/packages/b2c-cli/src/commands/am/clients/list.ts b/packages/b2c-cli/src/commands/am/clients/list.ts index a32e36e8b..1409eb6e4 100644 --- a/packages/b2c-cli/src/commands/am/clients/list.ts +++ b/packages/b2c-cli/src/commands/am/clients/list.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 {Flags, Errors} from '@oclif/core'; -import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {AmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerApiClient, APIClientCollection} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; @@ -82,15 +82,7 @@ export default class ClientList extends AmCommand { page: Flags.integer({ description: 'Page number (zero-based index, default: 0, min: 0)', }), - columns: Flags.string({ - char: 'c', - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: 'Show all columns including extended fields', - default: false, - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -134,8 +126,7 @@ export default class ClientList extends AmCommand { return result; } - const columns = this.getSelectedColumns(); - tableRenderer.render(clients, columns); + tableRenderer.render(clients, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); if (clients.length === pageSize) { const nextPage = pageNumber + 1; @@ -152,25 +143,4 @@ export default class ClientList extends AmCommand { return result; } - - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - const requested = columnsFlag.split(',').map((c) => c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/am/orgs/list.ts b/packages/b2c-cli/src/commands/am/orgs/list.ts index 768693114..1abd1ece8 100644 --- a/packages/b2c-cli/src/commands/am/orgs/list.ts +++ b/packages/b2c-cli/src/commands/am/orgs/list.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 {Flags, Errors} from '@oclif/core'; -import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {AmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerOrganization, OrganizationCollection} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; @@ -99,13 +99,7 @@ export default class OrgList extends AmCommand { char: 'a', description: 'Return all organizations (uses max page size of 5000)', }), - columns: Flags.string({ - description: 'Comma-separated list of columns to display', - }), - extended: Flags.boolean({ - char: 'x', - description: 'Show extended columns', - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -143,15 +137,10 @@ export default class OrgList extends AmCommand { return result; } - // Determine columns to display - let columnsToShow = DEFAULT_COLUMNS; - if (this.flags.columns) { - columnsToShow = this.flags.columns.split(',').map((c) => c.trim()); - } else if (this.flags.extended) { - columnsToShow = Object.keys(COLUMNS); - } - - tableRenderer.render(result.content, columnsToShow); + tableRenderer.render( + result.content, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); // Show pagination hint if more pages available const totalPages = result.totalPages ?? 0; diff --git a/packages/b2c-cli/src/commands/am/roles/get.ts b/packages/b2c-cli/src/commands/am/roles/get.ts index 221112657..687128ff9 100644 --- a/packages/b2c-cli/src/commands/am/roles/get.ts +++ b/packages/b2c-cli/src/commands/am/roles/get.ts @@ -3,9 +3,8 @@ * 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, ux} from '@oclif/core'; -import cliui from 'cliui'; -import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {Args} from '@oclif/core'; +import {AmCommand, printFieldsBlock, type DetailSection} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerRole} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; @@ -40,44 +39,28 @@ export default class RoleGet extends AmCommand { return role; } - this.printRoleDetails(role); - - return role; - } - - private printRoleDetails(role: AccountManagerRole): 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], - ['Role Enum Name', role.roleEnumName], - ['Scope', role.scope], - ['Target Type', role.targetType || undefined], - ['2FA Enabled', role.twoFAEnabled?.toString()], - ['Internal Role', (role as {internalRole?: boolean}).internalRole?.toString()], - ]; - - 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]}); - } - } - - // Permissions + const sections: DetailSection[] = []; if (role.permissions && role.permissions.length > 0) { - ui.div({text: 'Permissions', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - ui.div( - {text: 'Permissions:', width: 25, padding: [0, 2, 0, 0]}, - {text: role.permissions.join(', '), padding: [0, 0, 0, 0]}, - ); + sections.push({ + title: 'Permissions', + fields: [['Permissions', role.permissions.join(', ')]], + }); } - ux.stdout(ui.toString()); + printFieldsBlock( + 'Role Details', + [ + ['ID', role.id], + ['Description', role.description], + ['Role Enum Name', role.roleEnumName], + ['Scope', role.scope], + ['Target Type', role.targetType || undefined], + ['2FA Enabled', role.twoFAEnabled?.toString()], + ['Internal Role', (role as {internalRole?: boolean}).internalRole?.toString()], + ], + {sections}, + ); + + return role; } } diff --git a/packages/b2c-cli/src/commands/am/roles/list.ts b/packages/b2c-cli/src/commands/am/roles/list.ts index 3ce6d828e..63e3b882f 100644 --- a/packages/b2c-cli/src/commands/am/roles/list.ts +++ b/packages/b2c-cli/src/commands/am/roles/list.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 {Flags, Errors} from '@oclif/core'; -import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {AmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerRole, RoleCollection} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; @@ -76,15 +76,7 @@ export default class RoleList extends AmCommand { description: 'Filter by target type (User or ApiClient)', options: ['User', 'ApiClient'], }), - columns: Flags.string({ - char: 'c', - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: 'Show all columns including extended fields', - default: false, - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -132,7 +124,7 @@ export default class RoleList extends AmCommand { return result; } - tableRenderer.render(roles, this.getSelectedColumns()); + tableRenderer.render(roles, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); // Check if there are more pages (if we got a full page of results, there might be more) if (roles.length === pageSize) { @@ -146,28 +138,4 @@ export default class RoleList extends AmCommand { return result; } - - /** - * Determines which columns to display based on flags. - */ - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - const requested = columnsFlag.split(',').map((c) => c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/am/users/list.ts b/packages/b2c-cli/src/commands/am/users/list.ts index d42722cb5..0054a6652 100644 --- a/packages/b2c-cli/src/commands/am/users/list.ts +++ b/packages/b2c-cli/src/commands/am/users/list.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 {Flags, Errors} from '@oclif/core'; -import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {AmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerUser, UserCollection} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; @@ -96,15 +96,7 @@ export default class UserList extends AmCommand { page: Flags.integer({ description: 'Page number (zero-based index, default: 0, min: 0)', }), - columns: Flags.string({ - char: 'c', - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: 'Show all columns including extended fields', - default: false, - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -151,7 +143,7 @@ export default class UserList extends AmCommand { return result; } - tableRenderer.render(users, this.getSelectedColumns()); + tableRenderer.render(users, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); // Check if there are more pages (if we got a full page of results, there might be more) if (users.length === pageSize) { @@ -165,28 +157,4 @@ export default class UserList extends AmCommand { return result; } - - /** - * Determines which columns to display based on flags. - */ - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - const requested = columnsFlag.split(',').map((c) => c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/bm/access-key/create.ts b/packages/b2c-cli/src/commands/bm/access-key/create.ts new file mode 100644 index 000000000..cffee15db --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/access-key/create.ts @@ -0,0 +1,83 @@ +/* + * 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 {printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; +import {createBmUserAccessKey, type BmAccessKeyDetails} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; +import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; +import {t} from '../../../i18n/index.js'; + +const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; + +export default class BmAccessKeyCreate extends BmUserAuthCommand { + static args = { + login: Args.string({ + description: 'User login (email). Defaults to the currently authenticated user.', + required: false, + }), + }; + + static description = t( + 'commands.bm.accessKey.create.description', + 'Create or rotate an access key for an externally-managed Business Manager user. The secret value is only returned at creation time. Defaults to the current user.', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --scope STOREFRONT', + '<%= config.bin %> <%= command.id %> user@example.com --scope AGENT_USER_AND_OCAPI', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + scope: Flags.string({ + description: 'Access key scope', + options: [...ACCESS_KEY_SCOPES], + default: 'WEBDAV_AND_STUDIO', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {scope} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + const login = await resolveLoginOrWhoami(this.instance, this.args.login); + + this.log( + t('commands.bm.accessKey.create.creating', 'Creating {{scope}} access key for {{login}} on {{hostname}}...', { + login, + scope, + hostname, + }), + ); + + const details = await createBmUserAccessKey(this.instance, login, scope); + + if (this.jsonEnabled()) { + return details; + } + + this.log( + t( + 'commands.bm.accessKey.create.success', + 'Access key created. The secret value below is only shown once — record it now.', + ), + ); + + printFieldsBlock('Access Key', [ + ['Login', login], + ['Scope', scope], + ['Access key', details.access_key], + ['Enabled', details.enabled?.toString()], + ['Expiration date', details.expiration_date], + ]); + + return details; + } +} diff --git a/packages/b2c-cli/src/commands/bm/access-key/delete.ts b/packages/b2c-cli/src/commands/bm/access-key/delete.ts new file mode 100644 index 000000000..8251bcd4d --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/access-key/delete.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 {Args, Flags} from '@oclif/core'; +import {confirm as promptConfirm} from '@inquirer/prompts'; +import {deleteBmUserAccessKey} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; +import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; +import {t} from '../../../i18n/index.js'; + +const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; + +interface DeleteResult { + success: boolean; + login: string; + scope: string; + hostname: string; +} + +export default class BmAccessKeyDelete extends BmUserAuthCommand { + static args = { + login: Args.string({ + description: 'User login (email). Defaults to the currently authenticated user.', + required: false, + }), + }; + + static description = t( + 'commands.bm.accessKey.delete.description', + 'Delete an access key for an externally-managed Business Manager user. Defaults to the current user.', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --scope STOREFRONT --force', + '<%= config.bin %> <%= command.id %> user@example.com --scope AGENT_USER_AND_OCAPI', + ]; + + static flags = { + scope: Flags.string({ + description: 'Access key scope', + options: [...ACCESS_KEY_SCOPES], + default: 'WEBDAV_AND_STUDIO', + }), + force: Flags.boolean({ + description: 'Skip the confirmation prompt', + default: false, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {scope, force} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + const login = await resolveLoginOrWhoami(this.instance, this.args.login); + + if (!force && !this.jsonEnabled()) { + const answer = await promptConfirm({ + message: t( + 'commands.bm.accessKey.delete.confirm', + 'Delete the {{scope}} access key for {{login}} on {{hostname}}?', + {login, scope, hostname}, + ), + default: false, + }); + if (!answer) { + this.log(t('commands.bm.accessKey.delete.cancelled', 'Cancelled.')); + return {success: false, login, scope, hostname}; + } + } + + this.log( + t('commands.bm.accessKey.delete.deleting', 'Deleting {{scope}} access key for {{login}} on {{hostname}}...', { + login, + scope, + hostname, + }), + ); + + await deleteBmUserAccessKey(this.instance, login, scope); + + const result = {success: true, login, scope, hostname}; + + if (this.jsonEnabled()) { + return result; + } + + this.log( + t('commands.bm.accessKey.delete.success', 'Access key for {{login}} ({{scope}}) deleted.', {login, scope}), + ); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/bm/access-key/get.ts b/packages/b2c-cli/src/commands/bm/access-key/get.ts new file mode 100644 index 000000000..b57341553 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/access-key/get.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 {Args, Flags} from '@oclif/core'; +import {printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; +import {getBmUserAccessKey, type BmAccessKeyDetails} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; +import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; +import {t} from '../../../i18n/index.js'; + +const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; + +export default class BmAccessKeyGet extends BmUserAuthCommand { + static args = { + login: Args.string({ + description: 'User login (email). Defaults to the currently authenticated user.', + required: false, + }), + }; + + static description = t( + 'commands.bm.accessKey.get.description', + 'Get access key details for an externally-managed Business Manager user. Defaults to the current user.', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --scope STOREFRONT', + '<%= config.bin %> <%= command.id %> user@example.com --scope AGENT_USER_AND_OCAPI', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + scope: Flags.string({ + description: 'Access key scope', + options: [...ACCESS_KEY_SCOPES], + default: 'WEBDAV_AND_STUDIO', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {scope} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + const login = await resolveLoginOrWhoami(this.instance, this.args.login); + + this.log( + t('commands.bm.accessKey.get.fetching', 'Fetching {{scope}} access key for {{login}} on {{hostname}}...', { + login, + scope, + hostname, + }), + ); + + const details = await getBmUserAccessKey(this.instance, login, scope); + + if (this.jsonEnabled()) { + return details; + } + + printFieldsBlock('Access Key', [ + ['Login', login], + ['Scope', scope], + ['Enabled', details.enabled?.toString()], + ['Expiration date', details.expiration_date], + ]); + + return details; + } +} diff --git a/packages/b2c-cli/src/commands/bm/access-key/set.ts b/packages/b2c-cli/src/commands/bm/access-key/set.ts new file mode 100644 index 000000000..eb879fde4 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/access-key/set.ts @@ -0,0 +1,80 @@ +/* + * 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 {setBmUserAccessKeyEnabled, type BmAccessKeyDetails} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; +import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; +import {t} from '../../../i18n/index.js'; + +const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; + +export default class BmAccessKeySet extends BmUserAuthCommand { + static args = { + login: Args.string({ + description: 'User login (email). Defaults to the currently authenticated user.', + required: false, + }), + }; + + static description = t( + 'commands.bm.accessKey.set.description', + 'Enable or disable an existing access key for an externally-managed Business Manager user. Defaults to the current user.', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --enabled', + '<%= config.bin %> <%= command.id %> --no-enabled', + '<%= config.bin %> <%= command.id %> user@example.com --scope STOREFRONT --enabled', + ]; + + static flags = { + scope: Flags.string({ + description: 'Access key scope', + options: [...ACCESS_KEY_SCOPES], + default: 'WEBDAV_AND_STUDIO', + }), + enabled: Flags.boolean({ + description: 'Whether the access key should be enabled (use --no-enabled to disable)', + allowNo: true, + required: true, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {scope, enabled} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + const login = await resolveLoginOrWhoami(this.instance, this.args.login); + + this.log( + t('commands.bm.accessKey.set.updating', '{{verb}} {{scope}} access key for {{login}} on {{hostname}}...', { + verb: enabled ? 'Enabling' : 'Disabling', + login, + scope, + hostname, + }), + ); + + const details = await setBmUserAccessKeyEnabled(this.instance, login, scope, enabled); + + if (this.jsonEnabled()) { + return details; + } + + this.log( + t('commands.bm.accessKey.set.success', 'Access key for {{login}} ({{scope}}) is now {{state}}.', { + login, + scope, + state: details.enabled ? 'enabled' : 'disabled', + }), + ); + + return details; + } +} diff --git a/packages/b2c-cli/src/commands/bm/roles/get.ts b/packages/b2c-cli/src/commands/bm/roles/get.ts index 2d80ef873..74a9eee5e 100644 --- a/packages/b2c-cli/src/commands/bm/roles/get.ts +++ b/packages/b2c-cli/src/commands/bm/roles/get.ts @@ -3,9 +3,8 @@ * 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 {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 {t} from '../../../i18n/index.js'; @@ -50,45 +49,31 @@ export default class BmRolesGet extends InstanceCommand { 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]}); - } - } - + const sections: DetailSection[] = []; 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]}, - ); - } + sections.push({ + title: 'Assigned Users', + lines: role.users.map((user) => { + const login = user.login || '-'; + const name = [user.first_name, user.last_name].filter(Boolean).join(' '); + return name ? `${login} ${name}` : login; + }), + }); } - ux.stdout(ui.toString()); + printFieldsBlock( + 'Role Details', + [ + ['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], + ], + {sections}, + ); + + return role; } } diff --git a/packages/b2c-cli/src/commands/bm/roles/list.ts b/packages/b2c-cli/src/commands/bm/roles/list.ts index 521e141ae..680541dff 100644 --- a/packages/b2c-cli/src/commands/bm/roles/list.ts +++ b/packages/b2c-cli/src/commands/bm/roles/list.ts @@ -4,7 +4,13 @@ * 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 { + 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 {t} from '../../../i18n/index.js'; @@ -31,6 +37,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'userCount']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class BmRolesList extends InstanceCommand { static description = t('commands.bm.roles.list.description', 'List Business Manager access roles on an instance'); @@ -40,6 +48,8 @@ export default class BmRolesList extends InstanceCommand { '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --server my-sandbox.demandware.net', '<%= config.bin %> <%= command.id %> --count 50', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --columns id,description,userCount', '<%= config.bin %> <%= command.id %> --json', ]; @@ -51,6 +61,7 @@ export default class BmRolesList extends InstanceCommand { start: Flags.integer({ description: 'Start index for pagination (default 0)', }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -73,7 +84,7 @@ export default class BmRolesList extends InstanceCommand { return roles; } - createTable(COLUMNS).render(items, DEFAULT_COLUMNS); + tableRenderer.render(items, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); if (roles.total && roles.total > items.length) { this.log( diff --git a/packages/b2c-cli/src/commands/bm/users/delete.ts b/packages/b2c-cli/src/commands/bm/users/delete.ts new file mode 100644 index 000000000..10037f192 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/users/delete.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 {Args, Flags} from '@oclif/core'; +import {confirm as promptConfirm} from '@inquirer/prompts'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {deleteBmUser} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {t} from '../../../i18n/index.js'; + +interface DeleteResult { + success: boolean; + login: string; + hostname: string; +} + +export default class BmUsersDelete extends InstanceCommand { + static args = { + login: Args.string({ + description: 'User login (email) to delete', + required: true, + }), + }; + + static description = t('commands.bm.users.delete.description', 'Delete a Business Manager user from an instance'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --force', + '<%= config.bin %> <%= command.id %> user@example.com --json', + ]; + + static flags = { + force: Flags.boolean({ + description: 'Skip the confirmation prompt', + default: false, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {login} = this.args; + const {force} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + if (!force && !this.jsonEnabled()) { + const answer = await promptConfirm({ + message: t('commands.bm.users.delete.confirm', 'Delete user {{login}} from {{hostname}}?', {login, hostname}), + default: false, + }); + if (!answer) { + this.log(t('commands.bm.users.delete.cancelled', 'Cancelled.')); + return {success: false, login, hostname}; + } + } + + this.log(t('commands.bm.users.delete.deleting', 'Deleting user {{login}} from {{hostname}}...', {login, hostname})); + + await deleteBmUser(this.instance, login); + + const result = {success: true, login, hostname}; + + if (this.jsonEnabled()) { + return result; + } + + this.log(t('commands.bm.users.delete.success', 'User {{login}} deleted from {{hostname}}.', {login, hostname})); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/bm/users/get.ts b/packages/b2c-cli/src/commands/bm/users/get.ts new file mode 100644 index 000000000..ad5c4e591 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/users/get.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 + */ +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 {t} from '../../../i18n/index.js'; + +export default class BmUsersGet extends InstanceCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.bm.users.get.description', 'Get details of a Business Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --json', + ]; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {login} = this.args; + const hostname = this.resolvedConfig.values.hostname!; + + this.log(t('commands.bm.users.get.fetching', 'Fetching user {{login}} from {{hostname}}...', {login, hostname})); + + const user = await getBmUser(this.instance, login); + + if (this.jsonEnabled()) { + return user; + } + + printFieldsBlock( + 'User Details', + [ + ['Login', user.login], + ['Email', user.email], + ['First Name', user.first_name], + ['Last Name', user.last_name], + ['External ID', user.external_id], + ['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], + ], + { + sections: user.roles && user.roles.length > 0 ? [{title: 'Roles', lines: user.roles}] : [], + }, + ); + + return user; + } +} diff --git a/packages/b2c-cli/src/commands/bm/users/list.ts b/packages/b2c-cli/src/commands/bm/users/list.ts new file mode 100644 index 000000000..acbb4cdd7 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/users/list.ts @@ -0,0 +1,111 @@ +/* + * 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, + 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 {t} from '../../../i18n/index.js'; + +const COLUMNS: Record> = { + login: { + header: 'Login', + get: (u) => u.login || '-', + }, + email: { + header: 'Email', + get: (u) => u.email || '-', + }, + name: { + header: 'Name', + get: (u) => [u.first_name, u.last_name].filter(Boolean).join(' ') || '-', + }, + disabled: { + header: 'Disabled', + get: (u) => (u.disabled ? 'Yes' : 'No'), + }, + locked: { + header: 'Locked', + get: (u) => (u.locked ? 'Yes' : 'No'), + }, + lastLogin: { + header: 'Last Login', + get: (u) => u.last_login_date || '-', + extended: true, + }, + externalId: { + header: 'External ID', + get: (u) => u.external_id || '-', + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['login', 'name', 'disabled', 'locked']; + +const tableRenderer = new TableRenderer(COLUMNS); + +export default class BmUsersList extends InstanceCommand { + static description = t('commands.bm.users.list.description', 'List Business Manager users on an instance'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --count 50', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --columns login,email,lastLogin', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + count: Flags.integer({ + char: 'n', + description: 'Number of users to return (default 25)', + }), + start: Flags.integer({ + description: 'Start index for pagination (default 0)', + }), + ...columnFlagsFor(COLUMNS), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const hostname = this.resolvedConfig.values.hostname!; + const {count, start} = this.flags; + + this.log(t('commands.bm.users.list.fetching', 'Fetching users from {{hostname}}...', {hostname})); + + const users = await listBmUsers(this.instance, {count, start}); + + if (this.jsonEnabled()) { + return users; + } + + const items = users.data ?? []; + if (items.length === 0) { + this.log(t('commands.bm.users.list.noUsers', 'No users found.')); + return users; + } + + tableRenderer.render(items, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); + + if (users.total && users.total > items.length) { + this.log( + t('commands.bm.users.list.moreUsers', '{{count}} of {{total}} users shown.', { + count: items.length, + total: users.total, + }), + ); + } + + return users; + } +} diff --git a/packages/b2c-cli/src/commands/bm/users/search.ts b/packages/b2c-cli/src/commands/bm/users/search.ts new file mode 100644 index 000000000..d903a5e91 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/users/search.ts @@ -0,0 +1,149 @@ +/* + * 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, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; +import {searchBmUsers, type BmUser, type BmUserSearchResult} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {t} from '../../../i18n/index.js'; + +const COLUMNS: Record> = { + login: {header: 'Login', get: (u) => u.login || '-'}, + email: {header: 'Email', get: (u) => u.email || '-'}, + name: { + header: 'Name', + get: (u) => [u.first_name, u.last_name].filter(Boolean).join(' ') || '-', + }, + disabled: {header: 'Disabled', get: (u) => (u.disabled ? 'Yes' : 'No')}, + locked: {header: 'Locked', get: (u) => (u.locked ? 'Yes' : 'No')}, + lastLogin: {header: 'Last Login', get: (u) => u.last_login_date || '-'}, + externalId: {header: 'External ID', get: (u) => u.external_id || '-', extended: true}, +}; + +const DEFAULT_COLUMNS = ['login', 'name', 'disabled', 'locked', 'lastLogin']; + +const tableRenderer = new TableRenderer(COLUMNS); + +export default class BmUsersSearch extends InstanceCommand { + static description = t( + 'commands.bm.users.search.description', + 'Search Business Manager users by login, email, name, lock state, or disabled state', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --search-phrase smith', + '<%= config.bin %> <%= command.id %> --login user@example.com', + '<%= config.bin %> <%= command.id %> --email user@example.com', + '<%= config.bin %> <%= command.id %> --locked', + '<%= config.bin %> <%= command.id %> --disabled', + '<%= config.bin %> <%= command.id %> --sort-by last_login_date --sort-order desc', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --columns login,email,lastLogin', + '<%= config.bin %> <%= command.id %> --query \'{"text_query":{"fields":["login"],"search_phrase":"foo"}}\'', + ]; + + static flags = { + 'search-phrase': Flags.string({ + description: 'Free-text phrase searched across login, email, first_name, last_name', + }), + login: Flags.string({ + description: 'Match users with a specific login', + }), + email: Flags.string({ + description: 'Match users with a specific email', + }), + locked: Flags.boolean({ + description: 'Match locked users (use --no-locked for unlocked)', + allowNo: true, + }), + disabled: Flags.boolean({ + description: 'Match disabled users (use --no-disabled for enabled)', + allowNo: true, + }), + 'sort-by': Flags.string({ + description: 'Sort field (login, email, first_name, last_name, external_id, last_login_date)', + }), + 'sort-order': Flags.string({ + description: 'Sort order', + options: ['asc', 'desc'], + }), + query: Flags.string({ + description: 'Raw OCAPI query JSON (overrides convenience flags)', + }), + count: Flags.integer({ + char: 'n', + description: 'Number of users to return (default 25)', + }), + start: Flags.integer({ + description: 'Start index for pagination (default 0)', + }), + ...columnFlagsFor(COLUMNS), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const hostname = this.resolvedConfig.values.hostname!; + const flags = this.flags; + + let parsedQuery: unknown; + if (flags.query) { + try { + parsedQuery = JSON.parse(flags.query); + } catch (error) { + this.error( + t('commands.bm.users.search.invalidQuery', 'Invalid --query JSON: {{message}}', { + message: error instanceof Error ? error.message : String(error), + }), + ); + } + } + + this.log(t('commands.bm.users.search.searching', 'Searching users on {{hostname}}...', {hostname})); + + const result = await searchBmUsers(this.instance, { + query: parsedQuery, + searchPhrase: flags['search-phrase'], + login: flags.login, + email: flags.email, + locked: flags.locked, + disabled: flags.disabled, + sortBy: flags['sort-by'], + sortOrder: flags['sort-order'] as 'asc' | 'desc' | undefined, + start: flags.start, + count: flags.count, + }); + + if (this.jsonEnabled()) { + return result; + } + + const hits = result.hits ?? []; + if (hits.length === 0) { + this.log(t('commands.bm.users.search.noMatches', 'No users matched.')); + return result; + } + + tableRenderer.render(hits, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); + + if (result.total && result.total > hits.length) { + this.log( + t('commands.bm.users.search.moreUsers', '{{count}} of {{total}} users shown.', { + count: hits.length, + total: result.total, + }), + ); + } + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/bm/users/update.ts b/packages/b2c-cli/src/commands/bm/users/update.ts new file mode 100644 index 000000000..9b5a961c7 --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/users/update.ts @@ -0,0 +1,96 @@ +/* + * 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 {updateBmUser, type BmUser, type UpdateBmUserChanges} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {t} from '../../../i18n/index.js'; + +export default class BmUsersUpdate extends InstanceCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t( + 'commands.bm.users.update.description', + 'Update a Business Manager user attribute. Identity (password, lock state) is managed by Account Manager and cannot be modified here.', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --disabled', + '<%= config.bin %> <%= command.id %> user@example.com --no-disabled', + '<%= config.bin %> <%= command.id %> user@example.com --preferred-ui-locale en_US', + '<%= config.bin %> <%= command.id %> user@example.com --external-id ext-123', + '<%= config.bin %> <%= command.id %> user@example.com --first-name Jane --last-name Doe', + ]; + + static flags = { + disabled: Flags.boolean({ + description: 'Disable / enable the user (use --no-disabled to enable)', + allowNo: true, + }), + 'first-name': Flags.string({ + description: 'Set the user first name', + }), + 'last-name': Flags.string({ + description: 'Set the user last name', + }), + email: Flags.string({ + description: 'Set the user email', + }), + 'external-id': Flags.string({ + description: 'Set the external id (for centrally-authenticated users)', + }), + 'preferred-ui-locale': Flags.string({ + description: 'Set the preferred UI locale (e.g. en_US)', + }), + 'preferred-data-locale': Flags.string({ + description: 'Set the preferred data locale (e.g. en_US)', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {login} = this.args; + const flags = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + const changes: UpdateBmUserChanges = {}; + 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.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 (Object.keys(changes).length === 0) { + this.error( + t( + 'commands.bm.users.update.noChanges', + 'No fields specified. Provide at least one of --disabled, --first-name, --last-name, --email, --external-id, --preferred-ui-locale, --preferred-data-locale.', + ), + ); + } + + this.log(t('commands.bm.users.update.updating', 'Updating user {{login}} on {{hostname}}...', {login, hostname})); + + const user = await updateBmUser(this.instance, login, changes); + + if (this.jsonEnabled()) { + return user; + } + + this.log(t('commands.bm.users.update.success', 'User {{login}} updated on {{hostname}}.', {login, hostname})); + + return user; + } +} diff --git a/packages/b2c-cli/src/commands/bm/whoami.ts b/packages/b2c-cli/src/commands/bm/whoami.ts new file mode 100644 index 000000000..c0bbca20f --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/whoami.ts @@ -0,0 +1,46 @@ +/* + * 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 {printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; +import {whoamiBmUser, type BmUser} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmUserAuthCommand} from '../../utils/bm/user-auth-command.js'; +import {t} from '../../i18n/index.js'; + +export default class BmWhoami extends BmUserAuthCommand { + static description = t( + 'commands.bm.whoami.description', + 'Show details of the Business Manager user the current OAuth token resolves to', + ); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json']; + + async run(): Promise { + this.requireOAuthCredentials(); + + const hostname = this.resolvedConfig.values.hostname!; + + this.log(t('commands.bm.whoami.fetching', 'Resolving current user on {{hostname}}...', {hostname})); + + const user = await whoamiBmUser(this.instance); + + if (this.jsonEnabled()) { + return user; + } + + printFieldsBlock('Current User', [ + ['Login', user.login], + ['Email', user.email], + ['First Name', user.first_name], + ['Last Name', user.last_name], + ['External ID', user.external_id], + ['Last Login', user.last_login_date], + ['Password Expires', user.password_expiration_date], + ]); + + return user; + } +} diff --git a/packages/b2c-cli/src/commands/cap/list.ts b/packages/b2c-cli/src/commands/cap/list.ts index f4f2994aa..f5f072602 100644 --- a/packages/b2c-cli/src/commands/cap/list.ts +++ b/packages/b2c-cli/src/commands/cap/list.ts @@ -4,7 +4,13 @@ * 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 {JobCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + JobCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { discoverLocalApps, listInstalledApps, @@ -94,6 +100,17 @@ const REMOTE_DEFAULT_COLUMNS = [ 'installedAt', ]; +const localTableRenderer = new TableRenderer(LOCAL_COLUMNS); +const remoteTableRenderer = new TableRenderer(REMOTE_COLUMNS); + +// Merged column key set used solely so the --columns help text advertises keys +// from both local and remote tables. Each table validates against its own keys +// at render time via selectColumns. +const ALL_COLUMN_KEYS: Record = { + ...Object.fromEntries(Object.keys(LOCAL_COLUMNS).map((k) => [k, true])), + ...Object.fromEntries(Object.keys(REMOTE_COLUMNS).map((k) => [k, true])), +}; + export default class CapList extends JobCommand { static description = withDocs( t('commands.cap.list.description', 'List Commerce Apps locally or installed on a B2C Commerce instance'), @@ -129,6 +146,7 @@ export default class CapList extends JobCommand { char: 't', description: 'Timeout in seconds (default: no timeout)', }), + ...columnFlagsFor(ALL_COLUMN_KEYS), }; protected operations = { @@ -163,7 +181,10 @@ export default class CapList extends JobCommand { this.log(t('commands.cap.list.foundLocal', 'Found {{count}} Commerce App Package(s):', {count: apps.length})); if (!this.jsonEnabled()) { - createTable(LOCAL_COLUMNS).render(apps, LOCAL_DEFAULT_COLUMNS); + localTableRenderer.render( + apps, + selectColumns(this.flags, localTableRenderer, LOCAL_DEFAULT_COLUMNS, this.warn.bind(this)), + ); } return apps; @@ -206,7 +227,10 @@ export default class CapList extends JobCommand { ); if (!this.jsonEnabled()) { - createTable(REMOTE_COLUMNS).render(result.features, REMOTE_DEFAULT_COLUMNS); + remoteTableRenderer.render( + result.features, + selectColumns(this.flags, remoteTableRenderer, REMOTE_DEFAULT_COLUMNS, this.warn.bind(this)), + ); } return result; diff --git a/packages/b2c-cli/src/commands/code/list.ts b/packages/b2c-cli/src/commands/code/list.ts index 8ddf2ca9a..134705c1c 100644 --- a/packages/b2c-cli/src/commands/code/list.ts +++ b/packages/b2c-cli/src/commands/code/list.ts @@ -4,7 +4,13 @@ * 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 {InstanceCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + InstanceCommand, + 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 {t, withDocs} from '../../i18n/index.js'; @@ -33,6 +39,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'active', 'rollback', 'lastModified', 'cartridges']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class CodeList extends InstanceCommand { static description = withDocs( t('commands.code.list.description', 'List code versions on a B2C Commerce instance'), @@ -44,9 +52,15 @@ export default class CodeList extends InstanceCommand { static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --server my-sandbox.demandware.net', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --columns id,active,lastModified', '<%= config.bin %> <%= command.id %> --json', ]; + static flags = { + ...columnFlagsFor(COLUMNS), + }; + static hiddenAliases = ['code:list']; async run(): Promise { @@ -75,7 +89,7 @@ export default class CodeList extends InstanceCommand { return result; } - createTable(COLUMNS).render(versions, DEFAULT_COLUMNS); + tableRenderer.render(versions, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return result; } diff --git a/packages/b2c-cli/src/commands/content/list.ts b/packages/b2c-cli/src/commands/content/list.ts index 4b3a93c0b..173de3640 100644 --- a/packages/b2c-cli/src/commands/content/list.ts +++ b/packages/b2c-cli/src/commands/content/list.ts @@ -5,7 +5,13 @@ */ import {Flags} from '@oclif/core'; import {ux} from '@oclif/core'; -import {JobCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + JobCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {fetchContentLibrary} from '@salesforce/b2c-tooling-sdk/operations/content'; interface ContentListItem { @@ -36,6 +42,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'type', 'typeId', 'children']; +const tableRenderer = new TableRenderer(COLUMNS); + const TYPE_MAP: Record = { page: 'PAGE', content: 'CONTENT', @@ -80,6 +88,7 @@ export default class ContentList extends JobCommand { timeout: Flags.integer({ description: 'Job timeout in seconds', }), + ...columnFlagsFor(COLUMNS), }; protected operations = { @@ -158,7 +167,7 @@ export default class ContentList extends JobCommand { return {data: items}; } - createTable(COLUMNS).render(items, DEFAULT_COLUMNS); + tableRenderer.render(items, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return {data: items}; } diff --git a/packages/b2c-cli/src/commands/docs/search.ts b/packages/b2c-cli/src/commands/docs/search.ts index 0f5ba8f1a..c01ef9f31 100644 --- a/packages/b2c-cli/src/commands/docs/search.ts +++ b/packages/b2c-cli/src/commands/docs/search.ts @@ -4,7 +4,13 @@ * 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 {BaseCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + BaseCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {searchDocs, listDocs, type SearchResult, type DocEntry} from '@salesforce/b2c-tooling-sdk/docs'; import {t} from '../../i18n/index.js'; @@ -38,6 +44,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'title', 'score']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class DocsSearch extends BaseCommand { static args = { query: Args.string({ @@ -68,6 +76,7 @@ export default class DocsSearch extends BaseCommand { description: 'List all available documentation entries', default: false, }), + ...columnFlagsFor(COLUMNS), }; protected operations = { @@ -98,7 +107,7 @@ export default class DocsSearch extends BaseCommand { title: COLUMNS.title, }; - createTable(listColumns).render(results, ['id', 'title']); + new TableRenderer(listColumns).render(results, ['id', 'title']); this.log( t('commands.docs.search.totalCount', '{{count}} documentation entries available', {count: entries.length}), @@ -130,7 +139,7 @@ export default class DocsSearch extends BaseCommand { return response; } - createTable(COLUMNS).render(results, DEFAULT_COLUMNS); + tableRenderer.render(results, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); this.log( t('commands.docs.search.resultCount', 'Found {{count}} matches for "{{query}}"', { diff --git a/packages/b2c-cli/src/commands/ecdn/certificates/list.ts b/packages/b2c-cli/src/commands/ecdn/certificates/list.ts index d58d19be0..84ac4e494 100644 --- a/packages/b2c-cli/src/commands/ecdn/certificates/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/certificates/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../i18n/index.js'; @@ -73,15 +72,7 @@ export default class EcdnCertificatesList extends EcdnZoneCommand { @@ -132,33 +123,8 @@ export default class EcdnCertificatesList extends EcdnZoneCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/logpush/jobs/list.ts b/packages/b2c-cli/src/commands/ecdn/logpush/jobs/list.ts index 9f5296787..ee1956cc3 100644 --- a/packages/b2c-cli/src/commands/ecdn/logpush/jobs/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/logpush/jobs/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -76,15 +75,7 @@ export default class EcdnLogpushJobsList extends EcdnZoneCommand { @@ -135,30 +126,8 @@ export default class EcdnLogpushJobsList extends EcdnZoneCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/mtls/list.ts b/packages/b2c-cli/src/commands/ecdn/mtls/list.ts index 1e476c6ad..fe670df9d 100644 --- a/packages/b2c-cli/src/commands/ecdn/mtls/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/mtls/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnCommand, formatApiError} from '../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../i18n/index.js'; @@ -71,15 +70,7 @@ export default class EcdnMtlsList extends EcdnCommand { static flags = { ...EcdnCommand.baseFlags, - columns: Flags.string({ - char: 'c', - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: t('flags.extended.description', 'Show all columns including extended fields'), - default: false, - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -128,30 +119,8 @@ export default class EcdnMtlsList extends EcdnCommand { ); this.log(''); - const columns = this.getSelectedColumns(); - tableRenderer.render(certificates, columns); + tableRenderer.render(certificates, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return output; } - - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - const requested = columnsFlag.split(',').map((c) => c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/list.ts b/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/list.ts index 942c9cfd7..0e572f178 100644 --- a/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/page-shield/notifications/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -71,15 +70,7 @@ export default class EcdnPageShieldNotificationsList extends EcdnCommand { @@ -128,30 +119,8 @@ export default class EcdnPageShieldNotificationsList extends EcdnCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/page-shield/policies/list.ts b/packages/b2c-cli/src/commands/ecdn/page-shield/policies/list.ts index 2742e5f81..36ab29686 100644 --- a/packages/b2c-cli/src/commands/ecdn/page-shield/policies/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/page-shield/policies/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -71,15 +70,7 @@ export default class EcdnPageShieldPoliciesList extends EcdnZoneCommand { @@ -130,30 +121,8 @@ export default class EcdnPageShieldPoliciesList extends EcdnZoneCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/page-shield/scripts/list.ts b/packages/b2c-cli/src/commands/ecdn/page-shield/scripts/list.ts index e820842d4..75cc8bec1 100644 --- a/packages/b2c-cli/src/commands/ecdn/page-shield/scripts/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/page-shield/scripts/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -76,15 +75,7 @@ export default class EcdnPageShieldScriptsList extends EcdnZoneCommand { @@ -135,30 +126,8 @@ export default class EcdnPageShieldScriptsList extends EcdnZoneCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/waf/groups/list.ts b/packages/b2c-cli/src/commands/ecdn/waf/groups/list.ts index 4f0cc3707..820cac1fd 100644 --- a/packages/b2c-cli/src/commands/ecdn/waf/groups/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/waf/groups/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -61,10 +60,7 @@ export default class EcdnWafGroupsList extends EcdnZoneCommand { @@ -115,10 +111,7 @@ export default class EcdnWafGroupsList extends EcdnZoneCommand c.trim())) - : DEFAULT_COLUMNS; - tableRenderer.render(groups, columns.length > 0 ? columns : DEFAULT_COLUMNS); + tableRenderer.render(groups, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return output; } diff --git a/packages/b2c-cli/src/commands/ecdn/waf/managed-rules/list.ts b/packages/b2c-cli/src/commands/ecdn/waf/managed-rules/list.ts index 8834003a9..8c606d098 100644 --- a/packages/b2c-cli/src/commands/ecdn/waf/managed-rules/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/waf/managed-rules/list.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 {Flags} from '@oclif/core'; -import {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -75,15 +75,7 @@ export default class EcdnWafManagedRulesList extends EcdnZoneCommand { @@ -138,30 +130,8 @@ export default class EcdnWafManagedRulesList extends EcdnZoneCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/waf/rules/list.ts b/packages/b2c-cli/src/commands/ecdn/waf/rules/list.ts index 21e52f9e1..18180ec86 100644 --- a/packages/b2c-cli/src/commands/ecdn/waf/rules/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/waf/rules/list.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 {Flags} from '@oclif/core'; -import {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -70,15 +70,7 @@ export default class EcdnWafRulesList extends EcdnZoneCommand { @@ -131,30 +123,8 @@ export default class EcdnWafRulesList extends EcdnZoneCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/waf/rulesets/list.ts b/packages/b2c-cli/src/commands/ecdn/waf/rulesets/list.ts index 842e6515c..2e987f18b 100644 --- a/packages/b2c-cli/src/commands/ecdn/waf/rulesets/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/waf/rulesets/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnZoneCommand, formatApiError} from '../../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -71,15 +70,7 @@ export default class EcdnWafRulesetsList extends EcdnZoneCommand { @@ -130,30 +121,8 @@ export default class EcdnWafRulesetsList extends EcdnZoneCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/ecdn/zones/list.ts b/packages/b2c-cli/src/commands/ecdn/zones/list.ts index 05c9ab705..1dce7de65 100644 --- a/packages/b2c-cli/src/commands/ecdn/zones/list.ts +++ b/packages/b2c-cli/src/commands/ecdn/zones/list.ts @@ -3,8 +3,7 @@ * 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 {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {Zone} from '@salesforce/b2c-tooling-sdk/clients'; import {EcdnCommand, formatApiError} from '../../../utils/ecdn/index.js'; import {t, withDocs} from '../../../i18n/index.js'; @@ -57,15 +56,7 @@ export default class EcdnZonesList extends EcdnCommand { static flags = { ...EcdnCommand.baseFlags, - columns: Flags.string({ - char: 'c', - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: t('flags.extended.description', 'Show all columns including extended fields'), - default: false, - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -114,36 +105,8 @@ export default class EcdnZonesList extends EcdnCommand { ); this.log(''); - const columns = this.getSelectedColumns(); - tableRenderer.render(zones, columns); + tableRenderer.render(zones, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return output; } - - /** - * Determines which columns to display based on flags. - */ - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - // User specified explicit columns - const requested = columnsFlag.split(',').map((c) => c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - // Show all columns - return tableRenderer.getColumnKeys(); - } - - // Default columns (non-extended) - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index fac40acd7..2e1d9bcc8 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -4,7 +4,13 @@ * 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 { + InstanceCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { searchJobExecutions, type JobExecutionSearchResult, @@ -33,6 +39,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'jobId', 'status', 'startTime']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class JobSearch extends InstanceCommand { static description = withDocs( t('commands.job.search.description', 'Search for job executions on a B2C Commerce instance'), @@ -80,6 +88,7 @@ export default class JobSearch extends InstanceCommand { options: ['asc', 'desc'], default: 'desc', }), + ...columnFlagsFor(COLUMNS), }; protected operations = { @@ -124,7 +133,7 @@ export default class JobSearch extends InstanceCommand { }), ); - createTable(COLUMNS).render(results.hits, DEFAULT_COLUMNS); + tableRenderer.render(results.hits, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return results; } diff --git a/packages/b2c-cli/src/commands/logs/list.ts b/packages/b2c-cli/src/commands/logs/list.ts index 0b3139375..bf3cde5ac 100644 --- a/packages/b2c-cli/src/commands/logs/list.ts +++ b/packages/b2c-cli/src/commands/logs/list.ts @@ -4,7 +4,13 @@ * 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 { + InstanceCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {listLogFiles, type LogFile} from '@salesforce/b2c-tooling-sdk/operations/logs'; import {t} from '../../i18n/index.js'; @@ -43,6 +49,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['name', 'prefix', 'size', 'modified']; +const tableRenderer = new TableRenderer(COLUMNS); + interface LogsListResult { count: number; files: LogFile[]; @@ -78,6 +86,7 @@ export default class LogsList extends InstanceCommand { options: ['asc', 'desc'], default: 'desc', }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -108,7 +117,7 @@ export default class LogsList extends InstanceCommand { return result; } - createTable(COLUMNS).render(files, DEFAULT_COLUMNS); + tableRenderer.render(files, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return result; } diff --git a/packages/b2c-cli/src/commands/mrt/bundle/history.ts b/packages/b2c-cli/src/commands/mrt/bundle/history.ts index 6be3d39d7..89b8c5cc0 100644 --- a/packages/b2c-cli/src/commands/mrt/bundle/history.ts +++ b/packages/b2c-cli/src/commands/mrt/bundle/history.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listDeployments, type ListDeploymentsResult, @@ -41,6 +47,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['bundleId', 'bundleMessage', 'status', 'type', 'created']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List deployment history for an MRT environment. */ @@ -66,6 +74,7 @@ export default class MrtBundleHistory extends MrtCommand { @@ -107,7 +116,10 @@ export default class MrtBundleHistory extends MrtCommand> = { const DEFAULT_COLUMNS = ['id', 'message', 'status', 'user', 'created']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List bundles for an MRT project. */ @@ -58,6 +66,7 @@ export default class MrtBundleList extends MrtCommand { offset: Flags.integer({ description: 'Offset for pagination', }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -88,7 +97,10 @@ export default class MrtBundleList extends MrtCommand { this.log(t('commands.mrt.bundle.list.empty', 'No bundles found.')); } else { this.log(t('commands.mrt.bundle.list.count', 'Found {{count}} bundle(s):', {count: result.count})); - createTable(COLUMNS).render(result.bundles, DEFAULT_COLUMNS); + tableRenderer.render( + result.bundles, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); } } diff --git a/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts b/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts index 62fb4d139..8686765f1 100644 --- a/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listAccessControlHeaders, type ListAccessControlHeadersResult, @@ -33,6 +39,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'value', 'status', 'created']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List access control headers for an MRT environment. */ @@ -57,6 +65,7 @@ export default class MrtAccessControlList extends MrtCommand { @@ -106,7 +115,10 @@ export default class MrtAccessControlList extends MrtCommand> = { const DEFAULT_COLUMNS = ['field', 'value']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * Get or update B2C Commerce info for a target/environment. */ @@ -58,6 +66,7 @@ export default class MrtB2CTargetInfo extends MrtCommand { @@ -166,6 +175,6 @@ export default class MrtB2CTargetInfo extends MrtCommand 0 ? info.sites.join(', ') : 'None'}, ]; - createTable(COLUMNS).render(entries, DEFAULT_COLUMNS); + tableRenderer.render(entries, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); } } diff --git a/packages/b2c-cli/src/commands/mrt/env/list.ts b/packages/b2c-cli/src/commands/mrt/env/list.ts index 81a91334e..7be5d48c6 100644 --- a/packages/b2c-cli/src/commands/mrt/env/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/list.ts @@ -3,7 +3,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {listEnvs, type ListEnvsResult, type MrtEnvironment} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../i18n/index.js'; @@ -32,6 +38,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['name', 'slug', 'state', 'region', 'production']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List environments (targets) for an MRT project. */ @@ -50,6 +58,7 @@ export default class MrtEnvList extends MrtCommand { static flags = { ...MrtCommand.baseFlags, + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -78,7 +87,10 @@ export default class MrtEnvList extends MrtCommand { this.log( t('commands.mrt.env.list.count', 'Found {{count}} environment(s):', {count: result.environments.length}), ); - createTable(COLUMNS).render(result.environments, DEFAULT_COLUMNS); + tableRenderer.render( + result.environments, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); } } diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts index 209296636..3801d04fd 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {listRedirects, type ListRedirectsResult, type MrtRedirect} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -33,6 +39,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['fromPath', 'toUrl', 'status', 'publishingStatus']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List redirects for an MRT environment. */ @@ -61,6 +69,7 @@ export default class MrtRedirectList extends MrtCommand search: Flags.string({ description: 'Search term for filtering', }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -103,7 +112,10 @@ export default class MrtRedirectList extends MrtCommand this.log(t('commands.mrt.redirect.list.empty', 'No redirects found.')); } else { this.log(t('commands.mrt.redirect.list.count', 'Found {{count}} redirect(s):', {count: result.count})); - createTable(COLUMNS).render(result.redirects, DEFAULT_COLUMNS); + tableRenderer.render( + result.redirects, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); } } diff --git a/packages/b2c-cli/src/commands/mrt/env/var/list.ts b/packages/b2c-cli/src/commands/mrt/env/var/list.ts index 93267ebff..b05c86f5d 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/list.ts @@ -3,7 +3,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listEnvVars, type ListEnvVarsResult, @@ -32,6 +38,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['name', 'value', 'status', 'updated']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List environment variables on an MRT project environment. */ @@ -51,6 +59,7 @@ export default class MrtEnvVarList extends MrtCommand { static flags = { ...MrtCommand.baseFlags, + ...columnFlagsFor(COLUMNS), }; protected operations = { @@ -58,7 +67,7 @@ export default class MrtEnvVarList extends MrtCommand { }; protected renderTable(variables: EnvironmentVariable[]): void { - createTable(COLUMNS).render(variables, DEFAULT_COLUMNS); + tableRenderer.render(variables, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); } async run(): Promise { diff --git a/packages/b2c-cli/src/commands/mrt/org/b2c.ts b/packages/b2c-cli/src/commands/mrt/org/b2c.ts index cfc4dadbf..0c72d8652 100644 --- a/packages/b2c-cli/src/commands/mrt/org/b2c.ts +++ b/packages/b2c-cli/src/commands/mrt/org/b2c.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {getB2COrgInfo, type B2COrgInfo} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../i18n/index.js'; @@ -23,6 +29,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['field', 'value']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * Get B2C Commerce info for an organization. */ @@ -45,6 +53,7 @@ export default class MrtB2COrgInfo extends MrtCommand { static flags = { ...MrtCommand.baseFlags, + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -69,7 +78,7 @@ export default class MrtB2COrgInfo extends MrtCommand { {field: 'B2C Customer', value: info.is_b2c_customer ? 'Yes' : 'No'}, {field: 'Instances', value: info.instances.length > 0 ? info.instances.join(', ') : 'None'}, ]; - createTable(COLUMNS).render(entries, DEFAULT_COLUMNS); + tableRenderer.render(entries, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); } return info; diff --git a/packages/b2c-cli/src/commands/mrt/org/list.ts b/packages/b2c-cli/src/commands/mrt/org/list.ts index 0a4fbd70c..1d9e57958 100644 --- a/packages/b2c-cli/src/commands/mrt/org/list.ts +++ b/packages/b2c-cli/src/commands/mrt/org/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listOrganizations, type ListOrganizationsResult, @@ -33,6 +39,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['name', 'slug', 'status', 'created']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List MRT organizations accessible to the authenticated user. */ @@ -58,6 +66,7 @@ export default class MrtOrgList extends MrtCommand { offset: Flags.integer({ description: 'Offset for pagination', }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -81,7 +90,10 @@ export default class MrtOrgList extends MrtCommand { this.log(t('commands.mrt.org.list.empty', 'No organizations found.')); } else { this.log(t('commands.mrt.org.list.count', 'Found {{count}} organization(s):', {count: result.count})); - createTable(COLUMNS).render(result.organizations, DEFAULT_COLUMNS); + tableRenderer.render( + result.organizations, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); } } diff --git a/packages/b2c-cli/src/commands/mrt/project/list.ts b/packages/b2c-cli/src/commands/mrt/project/list.ts index 1acaaacdb..519d0a9c4 100644 --- a/packages/b2c-cli/src/commands/mrt/project/list.ts +++ b/packages/b2c-cli/src/commands/mrt/project/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {listProjects, type ListProjectsResult, type MrtProject} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../i18n/index.js'; @@ -33,6 +39,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['name', 'slug', 'organization', 'region']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List MRT projects accessible to the authenticated user. */ @@ -63,6 +71,7 @@ export default class MrtProjectList extends MrtCommand { offset: Flags.integer({ description: 'Offset for pagination', }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -87,7 +96,10 @@ export default class MrtProjectList extends MrtCommand { this.log(t('commands.mrt.project.list.empty', 'No projects found.')); } else { this.log(t('commands.mrt.project.list.count', 'Found {{count}} project(s):', {count: result.count})); - createTable(COLUMNS).render(result.projects, DEFAULT_COLUMNS); + tableRenderer.render( + result.projects, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); } } diff --git a/packages/b2c-cli/src/commands/mrt/project/member/list.ts b/packages/b2c-cli/src/commands/mrt/project/member/list.ts index eb3a182ab..ffd632794 100644 --- a/packages/b2c-cli/src/commands/mrt/project/member/list.ts +++ b/packages/b2c-cli/src/commands/mrt/project/member/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listMembers, type ListMembersResult, @@ -30,6 +36,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['email', 'role']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List members for an MRT project. */ @@ -63,6 +71,7 @@ export default class MrtMemberList extends MrtCommand { search: Flags.string({ description: 'Search term for filtering', }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -96,7 +105,10 @@ export default class MrtMemberList extends MrtCommand { } else { this.log(t('commands.mrt.member.list.count', 'Found {{count}} member(s):', {count: result.count})); this.log(t('commands.mrt.member.list.roles', 'Roles: Admin=0, Developer=1, Marketer=2, Read Only=3')); - createTable(COLUMNS).render(result.members, DEFAULT_COLUMNS); + tableRenderer.render( + result.members, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); } } diff --git a/packages/b2c-cli/src/commands/mrt/project/notification/list.ts b/packages/b2c-cli/src/commands/mrt/project/notification/list.ts index 2d7c78154..133d8e46c 100644 --- a/packages/b2c-cli/src/commands/mrt/project/notification/list.ts +++ b/packages/b2c-cli/src/commands/mrt/project/notification/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listNotifications, type ListNotificationsResult, @@ -39,6 +45,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'targets', 'recipients', 'events']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * List notifications for an MRT project. */ @@ -67,6 +75,7 @@ export default class MrtNotificationList extends MrtCommand { @@ -98,7 +107,10 @@ export default class MrtNotificationList extends MrtCommand> = { const DEFAULT_COLUMNS = ['field', 'value']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * View or update email notification preferences. */ @@ -50,6 +58,7 @@ export default class MrtUserEmailPrefs extends MrtCommand { @@ -109,6 +118,6 @@ export default class MrtUserEmailPrefs extends MrtCommand> = { const DEFAULT_COLUMNS = ['field', 'value']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * Get the current user's profile information. */ @@ -37,6 +45,7 @@ export default class MrtUserProfile extends MrtCommand { static flags = { ...MrtCommand.baseFlags, + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -60,7 +69,7 @@ export default class MrtUserProfile extends MrtCommand { {field: 'Joined', value: profile.date_joined ? new Date(profile.date_joined).toLocaleString() : '-'}, {field: 'UUID', value: profile.uuid ?? '-'}, ]; - createTable(COLUMNS).render(entries, DEFAULT_COLUMNS); + tableRenderer.render(entries, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); } return profile; diff --git a/packages/b2c-cli/src/commands/sandbox/alias/list.ts b/packages/b2c-cli/src/commands/sandbox/alias/list.ts index d863a0278..5e5c5e89a 100644 --- a/packages/b2c-cli/src/commands/sandbox/alias/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/alias/list.ts @@ -4,12 +4,45 @@ * 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 {OdsCommand, TableRenderer} from '@salesforce/b2c-tooling-sdk/cli'; +import { + OdsCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../../i18n/index.js'; type SandboxAliasModel = OdsComponents['schemas']['SandboxAliasModel']; +const COLUMNS: Record> = { + id: { + header: 'Alias ID', + get: (row) => row.id || '-', + }, + name: { + header: 'Hostname', + get: (row) => row.name, + }, + status: { + header: 'Status', + get: (row) => row.status || '-', + }, + unique: { + header: 'Unique', + get: (row) => (row.unique ? 'Yes' : 'No'), + }, + domainVerificationRecord: { + header: 'Verification Record', + get: (row) => row.domainVerificationRecord || '-', + }, +}; + +const DEFAULT_COLUMNS = ['id', 'name', 'status', 'unique', 'domainVerificationRecord']; + +const tableRenderer = new TableRenderer(COLUMNS); + /** * Command to list sandbox aliases. */ @@ -42,6 +75,7 @@ export default class SandboxAliasList extends OdsCommand { @@ -86,30 +120,7 @@ export default class SandboxAliasList extends OdsCommand row.id || '-', - }, - name: { - header: 'Hostname', - get: (row: SandboxAliasModel) => row.name, - }, - status: { - header: 'Status', - get: (row: SandboxAliasModel) => row.status || '-', - }, - unique: { - header: 'Unique', - get: (row: SandboxAliasModel) => (row.unique ? 'Yes' : 'No'), - }, - domainVerificationRecord: { - header: 'Verification Record', - get: (row: SandboxAliasModel) => row.domainVerificationRecord || '-', - }, - }; - const table = new TableRenderer(columns); - table.render(aliases, ['id', 'name', 'status', 'unique', 'domainVerificationRecord']); + tableRenderer.render(aliases, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); } } diff --git a/packages/b2c-cli/src/commands/sandbox/clone/list.ts b/packages/b2c-cli/src/commands/sandbox/clone/list.ts index 9f6697a53..1685ed19c 100644 --- a/packages/b2c-cli/src/commands/sandbox/clone/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/list.ts @@ -4,7 +4,13 @@ * 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 {OdsCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + OdsCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; @@ -100,15 +106,7 @@ export default class CloneList extends OdsCommand { required: false, options: ['Pending', 'InProgress', 'Failed', 'Completed'], }), - columns: Flags.string({ - char: 'c', - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: 'Show all columns', - default: false, - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise<{data?: SandboxCloneGetModel[]}> { @@ -146,25 +144,9 @@ export default class CloneList extends OdsCommand { return {data: clones}; } - const columns = this.getSelectedColumns(); const tableRenderer = new TableRenderer(COLUMNS); - tableRenderer.render(clones, columns); + tableRenderer.render(clones, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return {data: clones}; } - - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - return columnsFlag.split(',').map((c) => c.trim()); - } - - if (extended) { - return Object.keys(COLUMNS); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/sandbox/list.ts b/packages/b2c-cli/src/commands/sandbox/list.ts index 99d6f99d0..7b4e3fa7c 100644 --- a/packages/b2c-cli/src/commands/sandbox/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/list.ts @@ -4,7 +4,13 @@ * 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 {OdsCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + OdsCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../i18n/index.js'; @@ -119,15 +125,7 @@ export default class SandboxList extends OdsCommand { description: 'Include deleted sandboxes in the list', default: false, }), - columns: Flags.string({ - char: 'c', - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: 'Show all columns including extended fields', - default: false, - }), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -184,35 +182,8 @@ export default class SandboxList extends OdsCommand { return response; } - tableRenderer.render(sandboxes, this.getSelectedColumns()); + tableRenderer.render(sandboxes, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return response; } - - /** - * Determines which columns to display based on flags. - */ - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - // User specified explicit columns - const requested = columnsFlag.split(',').map((c) => c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - // Show all columns - return tableRenderer.getColumnKeys(); - } - - // Default columns (non-extended) - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/sandbox/operations/list.ts b/packages/b2c-cli/src/commands/sandbox/operations/list.ts index 880fc6562..3f6760fe5 100644 --- a/packages/b2c-cli/src/commands/sandbox/operations/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/operations/list.ts @@ -4,7 +4,13 @@ * 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 {OdsCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + OdsCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../../i18n/index.js'; @@ -113,15 +119,7 @@ export default class SandboxOperationsList extends OdsCommand { @@ -178,7 +176,7 @@ export default class SandboxOperationsList extends OdsCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/scaffold/list.ts b/packages/b2c-cli/src/commands/scaffold/list.ts index 331fd768f..7145568cc 100644 --- a/packages/b2c-cli/src/commands/scaffold/list.ts +++ b/packages/b2c-cli/src/commands/scaffold/list.ts @@ -4,7 +4,13 @@ * 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 {BaseCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + BaseCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { createScaffoldRegistry, type Scaffold, @@ -92,14 +98,7 @@ export default class ScaffoldList extends BaseCommand { description: 'Filter by source (built-in, user, project, plugin)', options: ['built-in', 'user', 'project', 'plugin'], }), - columns: Flags.string({ - description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, - }), - extended: Flags.boolean({ - char: 'x', - description: 'Show all columns including extended fields', - default: false, - }), + ...columnFlagsFor(COLUMNS, {columnsChar: false}), }; async run(): Promise { @@ -144,32 +143,8 @@ export default class ScaffoldList extends BaseCommand { this.log(t('commands.scaffold.list.foundScaffolds', 'Found {{count}} scaffold(s):', {count: scaffolds.length})); this.log(''); - tableRenderer.render(scaffolds, this.getSelectedColumns()); + tableRenderer.render(scaffolds, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); return response; } - - /** - * Determines which columns to display based on flags. - */ - private getSelectedColumns(): string[] { - const columnsFlag = this.flags.columns; - const extended = this.flags.extended; - - if (columnsFlag) { - const requested = columnsFlag.split(',').map((c) => c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - return tableRenderer.getColumnKeys(); - } - - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/scaffold/search.ts b/packages/b2c-cli/src/commands/scaffold/search.ts index 9b9a33b2b..08c00bcea 100644 --- a/packages/b2c-cli/src/commands/scaffold/search.ts +++ b/packages/b2c-cli/src/commands/scaffold/search.ts @@ -4,7 +4,13 @@ * 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 {BaseCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + BaseCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {createScaffoldRegistry, type Scaffold, type ScaffoldCategory} from '@salesforce/b2c-tooling-sdk/scaffold'; import {t, withDocs} from '../../i18n/index.js'; @@ -81,6 +87,7 @@ export default class ScaffoldSearch extends BaseCommand { char: 'c', description: 'Filter results by category', }), + ...columnFlagsFor(COLUMNS, {columnsChar: false}), }; async run(): Promise { @@ -123,7 +130,7 @@ export default class ScaffoldSearch extends BaseCommand { ); this.log(''); - tableRenderer.render(scaffolds, DEFAULT_COLUMNS); + tableRenderer.render(scaffolds, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); this.log(''); this.log(t('commands.scaffold.search.hint', 'Use "b2c scaffold info " for more details')); diff --git a/packages/b2c-cli/src/commands/scapi/custom/status.ts b/packages/b2c-cli/src/commands/scapi/custom/status.ts index bc67c0246..7e4d69af8 100644 --- a/packages/b2c-cli/src/commands/scapi/custom/status.ts +++ b/packages/b2c-cli/src/commands/scapi/custom/status.ts @@ -4,7 +4,13 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Command, Flags, ux} from '@oclif/core'; -import {OAuthCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + OAuthCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { createCustomApisClient, getApiErrorMessage, @@ -187,15 +193,7 @@ export default class ScapiCustomStatus extends ScapiCustomCommand { @@ -285,33 +283,6 @@ export default class ScapiCustomStatus extends ScapiCustomCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - // Show all columns - return tableRenderer.getColumnKeys(); - } - - // Default columns (non-extended) - return DEFAULT_COLUMNS; - } - /** * Groups endpoints by a key function. */ @@ -333,7 +304,7 @@ export default class ScapiCustomStatus extends ScapiCustomCommand { @@ -100,38 +92,11 @@ export default class ReplicationsList extends GranularReplicationsCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - // Show all columns - return Object.keys(COLUMNS); - } - - // Show default columns - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/scapi/schemas/list.ts b/packages/b2c-cli/src/commands/scapi/schemas/list.ts index 6c9aba046..7160547c5 100644 --- a/packages/b2c-cli/src/commands/scapi/schemas/list.ts +++ b/packages/b2c-cli/src/commands/scapi/schemas/list.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 {Flags} from '@oclif/core'; -import {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {SchemaListItem} from '@salesforce/b2c-tooling-sdk/clients'; import {ScapiSchemasCommand, formatApiError} from '../../../utils/scapi/schemas.js'; import {t, withDocs} from '../../../i18n/index.js'; @@ -87,15 +87,7 @@ export default class ScapiSchemasList extends ScapiSchemasCommand { @@ -151,36 +143,8 @@ export default class ScapiSchemasList extends ScapiSchemasCommand c.trim()); - const valid = tableRenderer.validateColumnKeys(requested); - if (valid.length === 0) { - this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); - return DEFAULT_COLUMNS; - } - return valid; - } - - if (extended) { - // Show all columns - return tableRenderer.getColumnKeys(); - } - - // Default columns (non-extended) - return DEFAULT_COLUMNS; - } } diff --git a/packages/b2c-cli/src/commands/setup/instance/list.ts b/packages/b2c-cli/src/commands/setup/instance/list.ts index 4879e2a8d..f3ec9b6c8 100644 --- a/packages/b2c-cli/src/commands/setup/instance/list.ts +++ b/packages/b2c-cli/src/commands/setup/instance/list.ts @@ -4,7 +4,13 @@ * 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 {BaseCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + BaseCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {DwJsonSource, type InstanceInfo} from '@salesforce/b2c-tooling-sdk/config'; import {withDocs} from '../../../i18n/index.js'; @@ -32,6 +38,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['name', 'hostname', 'source', 'active']; +const tableRenderer = new TableRenderer(COLUMNS); + /** * JSON output structure for the list command. */ @@ -52,6 +60,7 @@ export default class SetupInstanceList extends BaseCommand { @@ -81,7 +90,7 @@ export default class SetupInstanceList extends BaseCommand> = { const DEFAULT_SKILL_COLUMNS = ['name', 'description', 'skillSet']; +const skillTableRenderer = new TableRenderer(SKILL_COLUMNS); + /** * Response type for JSON output. */ @@ -123,6 +131,7 @@ export default class SetupSkills extends BaseCommand { description: 'Skip confirmation prompts (non-interactive)', default: false, }), + ...columnFlagsFor(SKILL_COLUMNS), }; async run(): Promise { @@ -200,7 +209,10 @@ export default class SetupSkills extends BaseCommand { return {skills: []}; } - createTable(SKILL_COLUMNS).render(allSkills, DEFAULT_SKILL_COLUMNS); + skillTableRenderer.render( + allSkills, + selectColumns(this.flags, skillTableRenderer, DEFAULT_SKILL_COLUMNS, this.warn.bind(this)), + ); return {skills: allSkills}; } diff --git a/packages/b2c-cli/src/commands/sites/list.ts b/packages/b2c-cli/src/commands/sites/list.ts index 4ebe3f359..03fe99930 100644 --- a/packages/b2c-cli/src/commands/sites/list.ts +++ b/packages/b2c-cli/src/commands/sites/list.ts @@ -4,7 +4,13 @@ * 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 {InstanceCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + InstanceCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients'; import type {OcapiComponents} from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../i18n/index.js'; @@ -29,6 +35,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'displayName', 'status']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class SitesList extends InstanceCommand { static description = withDocs( t('commands.sites.list.description', 'List sites on a B2C Commerce instance'), @@ -40,9 +48,15 @@ export default class SitesList extends InstanceCommand { static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --server my-sandbox.demandware.net', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --columns id,status', '<%= config.bin %> <%= command.id %> --json', ]; + static flags = { + ...columnFlagsFor(COLUMNS), + }; + async run(): Promise { this.requireOAuthCredentials(); @@ -75,7 +89,10 @@ export default class SitesList extends InstanceCommand { return sites; } - createTable(COLUMNS).render(sites.data ?? [], DEFAULT_COLUMNS); + tableRenderer.render( + sites.data ?? [], + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); return sites; } diff --git a/packages/b2c-cli/src/commands/slas/client/list.ts b/packages/b2c-cli/src/commands/slas/client/list.ts index 9f6d8519e..ce0606593 100644 --- a/packages/b2c-cli/src/commands/slas/client/list.ts +++ b/packages/b2c-cli/src/commands/slas/client/list.ts @@ -3,7 +3,7 @@ * 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 {createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import { SlasClientCommand, type Client, @@ -34,6 +34,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['clientId', 'name', 'isPrivate']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class SlasClientList extends SlasClientCommand { static description = withDocs( t('commands.slas.client.list.description', 'List SLAS clients for a tenant'), @@ -49,6 +51,7 @@ export default class SlasClientList extends SlasClientCommand { @@ -93,7 +96,7 @@ export default class SlasClientList extends SlasClientCommand> = { const DEFAULT_COLUMNS = ['name', 'type', 'size']; +const tableRenderer = new TableRenderer(COLUMNS); + interface LsResult { path: string; count: number; @@ -87,9 +95,15 @@ export default class WebDavLs extends WebDavCommand { '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> src/instance', '<%= config.bin %> <%= command.id %> --root=cartridges', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --columns name,size,modified', '<%= config.bin %> <%= command.id %> --root=logs --json', ]; + static flags = { + ...columnFlagsFor(COLUMNS), + }; + async run(): Promise { this.ensureWebDavAuth(); @@ -121,7 +135,10 @@ export default class WebDavLs extends WebDavCommand { return result; } - createTable(COLUMNS).render(filteredEntries, DEFAULT_COLUMNS); + tableRenderer.render( + filteredEntries, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); return result; } diff --git a/packages/b2c-cli/src/utils/am/user-display.ts b/packages/b2c-cli/src/utils/am/user-display.ts index 44a160a45..ba93187ad 100644 --- a/packages/b2c-cli/src/utils/am/user-display.ts +++ b/packages/b2c-cli/src/utils/am/user-display.ts @@ -3,8 +3,7 @@ * 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 cliui from 'cliui'; +import {printFieldsBlock, type DetailField, type DetailSection} from '@salesforce/b2c-tooling-sdk/cli'; import {resolveToInternalRole} from '@salesforce/b2c-tooling-sdk'; import type {AccountManagerUser, RoleMapping, OrgMapping} from '@salesforce/b2c-tooling-sdk'; @@ -22,14 +21,14 @@ function formatOrgDisplay(orgId: string, orgMapping: OrgMapping): string { return name ? `${name} (${orgId})` : orgId; } -function printBasicFields(ui: ReturnType, user: AccountManagerUser, orgMapping: OrgMapping): void { +function buildBasicFields(user: AccountManagerUser, orgMapping: OrgMapping): DetailField[] { const isPasswordExpired = user.passwordExpirationTimestamp ? user.passwordExpirationTimestamp < Date.now() : undefined; const twoFAEnabled = user.verifiers && user.verifiers.length > 0 ? 'Yes' : 'No'; const primaryOrg = user.primaryOrganization ? formatOrgDisplay(user.primaryOrganization, orgMapping) : undefined; - const fields: [string, string | undefined][] = [ + return [ ['ID', user.id], ['Email', user.mail], ['First Name', user.firstName], @@ -48,72 +47,59 @@ function printBasicFields(ui: ReturnType, user: AccountManagerUser ['Created At', user.createdAt ? new Date(user.createdAt).toLocaleString() : undefined], ['Last Modified', user.lastModified ? new Date(user.lastModified).toLocaleString() : undefined], ]; - - 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]}); - } - } } -function printOrganizations(ui: ReturnType, user: AccountManagerUser, orgMapping: OrgMapping): void { +function buildOrganizationsSection(user: AccountManagerUser, orgMapping: OrgMapping): DetailSection | undefined { if (!user.organizations || user.organizations.length === 0) { - return; - } - - ui.div({text: 'Organizations', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - for (const o of user.organizations) { - const orgId = typeof o === 'string' ? o : o.id || 'Unknown'; - const name = formatOrgDisplay(orgId, orgMapping); - ui.div({text: `- ${name}`, padding: [0, 0, 0, 2]}); + return undefined; } + return { + title: 'Organizations', + lines: user.organizations.map((o) => { + const orgId = typeof o === 'string' ? o : o.id || 'Unknown'; + return `- ${formatOrgDisplay(orgId, orgMapping)}`; + }), + }; } -function printRoles(ui: ReturnType, user: AccountManagerUser, roleMapping: RoleMapping): void { +function buildRolesSection(user: AccountManagerUser, roleMapping: RoleMapping): DetailSection | undefined { if (!user.roles || user.roles.length === 0) { - return; - } - - ui.div({text: 'Roles', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - for (const r of user.roles) { - const name = typeof r === 'string' ? r : r.roleEnumName || r.id || 'Unknown'; - const display = formatRoleDisplay(name, roleMapping); - ui.div({text: `- ${display}`, padding: [0, 0, 0, 2]}); + return undefined; } + return { + title: 'Roles', + lines: user.roles.map((r) => { + const name = typeof r === 'string' ? r : r.roleEnumName || r.id || 'Unknown'; + return `- ${formatRoleDisplay(name, roleMapping)}`; + }), + }; } -function printRoleScopes(ui: ReturnType, user: AccountManagerUser): void { +function buildRoleScopesSection(user: AccountManagerUser): DetailSection | undefined { if (!user.roleTenantFilterMap || Object.keys(user.roleTenantFilterMap).length === 0) { - return; - } - - ui.div({text: 'Role Scopes', padding: [2, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - for (const [roleEnumName, filter] of Object.entries(user.roleTenantFilterMap as Record)) { - const filterValue = - typeof filter === 'string' ? filter : Array.isArray(filter) ? filter.join(', ') : String(filter); - ui.div({text: `${roleEnumName}:`, width: 30, padding: [0, 2, 0, 0]}, {text: filterValue, padding: [0, 0, 0, 0]}); + return undefined; } + const fields: DetailField[] = Object.entries(user.roleTenantFilterMap as Record).map( + ([roleEnumName, filter]) => { + const filterValue = + typeof filter === 'string' ? filter : Array.isArray(filter) ? filter.join(', ') : String(filter); + return [roleEnumName, filterValue]; + }, + ); + return {title: 'Role Scopes', fields}; } /** * Prints user details to stdout using cliui formatting. */ export function printUserDetails(user: AccountManagerUser, roleMapping: RoleMapping, orgMapping: OrgMapping): void { - const ui = cliui({width: process.stdout.columns || 80}); - - ui.div({text: 'User Details', padding: [1, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - printBasicFields(ui, user, orgMapping); - printOrganizations(ui, user, orgMapping); - printRoles(ui, user, roleMapping); - printRoleScopes(ui, user); - - ux.stdout(ui.toString()); + const sections: DetailSection[] = []; + const orgs = buildOrganizationsSection(user, orgMapping); + if (orgs) sections.push(orgs); + const roles = buildRolesSection(user, roleMapping); + if (roles) sections.push(roles); + const scopes = buildRoleScopesSection(user); + if (scopes) sections.push(scopes); + + printFieldsBlock('User Details', buildBasicFields(user, orgMapping), {sections}); } diff --git a/packages/b2c-cli/src/utils/bm/resolve-login.ts b/packages/b2c-cli/src/utils/bm/resolve-login.ts new file mode 100644 index 000000000..d17c60a87 --- /dev/null +++ b/packages/b2c-cli/src/utils/bm/resolve-login.ts @@ -0,0 +1,21 @@ +/* + * 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 '@salesforce/b2c-tooling-sdk/instance'; +import {whoamiBmUser} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; + +/** + * Returns `login` if provided, otherwise resolves the current authenticated + * user's login via `GET /users/this`. Used by access-key commands so callers + * can manage their own keys without restating the email. + */ +export async function resolveLoginOrWhoami(instance: B2CInstance, login: string | undefined): Promise { + if (login) return login; + const user = await whoamiBmUser(instance); + if (!user.login) { + throw new Error('Could not resolve current user login from /users/this — pass an explicit login.'); + } + return user.login; +} diff --git a/packages/b2c-cli/src/utils/bm/user-auth-command.ts b/packages/b2c-cli/src/utils/bm/user-auth-command.ts new file mode 100644 index 000000000..0a1e6b12f --- /dev/null +++ b/packages/b2c-cli/src/utils/bm/user-auth-command.ts @@ -0,0 +1,39 @@ +/* + * 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 {Command} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AuthMethod} from '@salesforce/b2c-tooling-sdk/auth'; +import type {ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; + +/** + * Base for Data API commands whose endpoints state "A valid user is required" + * in the OCAPI documentation — i.e. the OAuth token must resolve to a real + * Business Manager user, not a service client. Examples: `bm whoami` + * (`/users/this`) and the access-key endpoints under + * `/users/{login}/access_key`. + * + * Defaults `authMethods` to `['implicit']` so the underlying + * {@link InstanceCommand}'s `B2CInstance` chooses browser-based user-auth + * rather than client-credentials. The default is applied *after* config + * resolution and only when the user has not specified `authMethods` via + * `--auth-methods`, `--user-auth`, dw.json, or `SFCC_AUTH_METHODS`. + */ +export abstract class BmUserAuthCommand extends InstanceCommand { + protected override getDefaultAuthMethods(): AuthMethod[] { + return ['implicit']; + } + + protected override async loadConfiguration(): Promise { + const resolved = await super.loadConfiguration(); + if (!resolved.values.authMethods) { + // Patch in our default so B2CInstance's auth selection (which doesn't + // consult getDefaultAuthMethods()) prefers user-auth too. + // ResolvedB2CConfig.values is typed readonly but not frozen. + (resolved.values as {authMethods?: AuthMethod[]}).authMethods = this.getDefaultAuthMethods(); + } + return resolved; + } +} diff --git a/packages/b2c-cli/test/commands/am/roles/list.test.ts b/packages/b2c-cli/test/commands/am/roles/list.test.ts index 432975f04..bf76c21ed 100644 --- a/packages/b2c-cli/test/commands/am/roles/list.test.ts +++ b/packages/b2c-cli/test/commands/am/roles/list.test.ts @@ -61,33 +61,6 @@ describe('role list', () => { }); }); - describe('getSelectedColumns', () => { - it('should return default columns when no flags provided', () => { - const command = new RoleList([], {} as any); - (command as any).flags = {}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.deep.equal(['id', 'description', 'roleEnumName']); - }); - - it('should return all columns when --extended flag is set', () => { - const command = new RoleList([], {} as any); - (command as any).flags = {extended: true}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.include('id'); - expect(columns).to.include('permissions'); - }); - - it('should return custom columns when --columns flag is set', () => { - const command = new RoleList([], {} as any); - (command as any).flags = {columns: 'id,description,scope'}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.deep.equal(['id', 'description', 'scope']); - }); - }); - describe('pagination validation', () => { it('should validate size parameter - minimum', async () => { const command = new RoleList([], {} as any); diff --git a/packages/b2c-cli/test/commands/am/users/list.test.ts b/packages/b2c-cli/test/commands/am/users/list.test.ts index afe47b399..9f62a6590 100644 --- a/packages/b2c-cli/test/commands/am/users/list.test.ts +++ b/packages/b2c-cli/test/commands/am/users/list.test.ts @@ -86,43 +86,6 @@ describe('user list', () => { }); }); - describe('getSelectedColumns', () => { - it('should return default columns when no flags provided', () => { - const command = new UserList([], {} as any); - (command as any).flags = {}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.deep.equal([ - 'mail', - 'firstName', - 'lastName', - 'userState', - 'passwordExpired', - 'twoFAEnabled', - 'linkedToSfIdentity', - 'lastLoginDate', - ]); - }); - - it('should return all columns when --extended flag is set', () => { - const command = new UserList([], {} as any); - (command as any).flags = {extended: true}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.include('mail'); - expect(columns).to.include('roles'); - expect(columns).to.include('organizations'); - }); - - it('should return custom columns when --columns flag is set', () => { - const command = new UserList([], {} as any); - (command as any).flags = {columns: 'mail,firstName,userState'}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.deep.equal(['mail', 'firstName', 'userState']); - }); - }); - describe('pagination validation', () => { it('should validate size parameter - minimum', async () => { const command = new UserList([], {} as any); diff --git a/packages/b2c-cli/test/commands/ecdn/certificates/list.test.ts b/packages/b2c-cli/test/commands/ecdn/certificates/list.test.ts index d70aa724f..b017322a2 100644 --- a/packages/b2c-cli/test/commands/ecdn/certificates/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/certificates/list.test.ts @@ -132,27 +132,4 @@ describe('ecdn certificates list', () => { expect(errorStub.calledOnce).to.equal(true); } }); - - it('shows all columns with --extended flag', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', zone: 'my-zone', extended: true}); - - const columns = command.getSelectedColumns(); - - expect(columns).to.include('certificateId'); - expect(columns).to.include('hosts'); - expect(columns).to.include('status'); - expect(columns).to.include('expiresOn'); - }); - - it('supports custom columns with --columns flag', async () => { - const command: any = await createCommand({ - 'tenant-id': 'zzxy_prd', - zone: 'my-zone', - columns: 'certificateId,status', - }); - - const columns = command.getSelectedColumns(); - - expect(columns).to.deep.equal(['certificateId', 'status']); - }); }); diff --git a/packages/b2c-cli/test/commands/ecdn/logpush/jobs/list.test.ts b/packages/b2c-cli/test/commands/ecdn/logpush/jobs/list.test.ts index fa204debc..074a7ead4 100644 --- a/packages/b2c-cli/test/commands/ecdn/logpush/jobs/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/logpush/jobs/list.test.ts @@ -134,23 +134,4 @@ describe('ecdn logpush jobs list', () => { expect(errorStub.calledOnce).to.equal(true); } }); - - it('shows all columns with --extended flag', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', zone: 'my-zone', extended: true}); - - const columns = command.getSelectedColumns(); - - expect(columns).to.include('jobId'); - expect(columns).to.include('name'); - expect(columns).to.include('destinationPath'); - expect(columns).to.include('lastComplete'); - }); - - it('supports custom columns with --columns flag', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', zone: 'my-zone', columns: 'jobId,name'}); - - const columns = command.getSelectedColumns(); - - expect(columns).to.deep.equal(['jobId', 'name']); - }); }); diff --git a/packages/b2c-cli/test/commands/ecdn/mtls/list.test.ts b/packages/b2c-cli/test/commands/ecdn/mtls/list.test.ts index 76d7edfd6..f2442f471 100644 --- a/packages/b2c-cli/test/commands/ecdn/mtls/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/mtls/list.test.ts @@ -132,26 +132,4 @@ describe('ecdn mtls list', () => { expect(errorStub.calledOnce).to.equal(true); } }); - - it('shows all columns with --extended flag', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', extended: true}); - - const columns = command.getSelectedColumns(); - - expect(columns).to.include('mtlsCertificateId'); - expect(columns).to.include('mtlsCertificateName'); - expect(columns).to.include('mtlsAssociatedCodeUploadHostname'); - expect(columns).to.include('ca'); - }); - - it('supports custom columns with --columns flag', async () => { - const command: any = await createCommand({ - 'tenant-id': 'zzxy_prd', - columns: 'mtlsCertificateId,mtlsCertificateName', - }); - - const columns = command.getSelectedColumns(); - - expect(columns).to.deep.equal(['mtlsCertificateId', 'mtlsCertificateName']); - }); }); diff --git a/packages/b2c-cli/test/commands/ecdn/page-shield/notifications/list.test.ts b/packages/b2c-cli/test/commands/ecdn/page-shield/notifications/list.test.ts index 52d85b013..458c546c8 100644 --- a/packages/b2c-cli/test/commands/ecdn/page-shield/notifications/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/page-shield/notifications/list.test.ts @@ -112,17 +112,4 @@ describe('ecdn page-shield notifications list', () => { expect(errorStub.calledOnce).to.equal(true); } }); - - it('shows extended columns', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', extended: true}); - const columns = command.getSelectedColumns(); - expect(columns).to.include('createdAt'); - expect(columns).to.include('zones'); - }); - - it('supports custom columns', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', columns: 'id,name'}); - const columns = command.getSelectedColumns(); - expect(columns).to.deep.equal(['id', 'name']); - }); }); diff --git a/packages/b2c-cli/test/commands/ecdn/page-shield/policies/list.test.ts b/packages/b2c-cli/test/commands/ecdn/page-shield/policies/list.test.ts index cab9b04e9..211edbb65 100644 --- a/packages/b2c-cli/test/commands/ecdn/page-shield/policies/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/page-shield/policies/list.test.ts @@ -114,17 +114,4 @@ describe('ecdn page-shield policies list', () => { expect(errorStub.calledOnce).to.equal(true); } }); - - it('shows extended columns', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', zone: 'my-zone', extended: true}); - const columns = command.getSelectedColumns(); - expect(columns).to.include('value'); - expect(columns).to.include('expression'); - }); - - it('supports custom columns', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', zone: 'my-zone', columns: 'id,action'}); - const columns = command.getSelectedColumns(); - expect(columns).to.deep.equal(['id', 'action']); - }); }); diff --git a/packages/b2c-cli/test/commands/ecdn/page-shield/scripts/list.test.ts b/packages/b2c-cli/test/commands/ecdn/page-shield/scripts/list.test.ts index b4707fcfc..ab1fc5cf4 100644 --- a/packages/b2c-cli/test/commands/ecdn/page-shield/scripts/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/page-shield/scripts/list.test.ts @@ -137,23 +137,4 @@ describe('ecdn page-shield scripts list', () => { expect(errorStub.calledOnce).to.equal(true); } }); - - it('shows all columns with --extended flag', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', zone: 'my-zone', extended: true}); - - const columns = command.getSelectedColumns(); - - expect(columns).to.include('id'); - expect(columns).to.include('url'); - expect(columns).to.include('malwareScore'); - expect(columns).to.include('lastSeenAt'); - }); - - it('supports custom columns with --columns flag', async () => { - const command: any = await createCommand({'tenant-id': 'zzxy_prd', zone: 'my-zone', columns: 'id,url,status'}); - - const columns = command.getSelectedColumns(); - - expect(columns).to.deep.equal(['id', 'url', 'status']); - }); }); diff --git a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts index e8a4ad8f0..9c086a7d4 100644 --- a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts @@ -42,40 +42,6 @@ describe('ecdn zones list', () => { Object.defineProperty(command, '_cdnZonesRwClient', {value: client, configurable: true, writable: true}); } - describe('getSelectedColumns', () => { - it('returns default columns when no flags provided', async () => { - const command: any = await createCommand({}); - const columns = command.getSelectedColumns(); - - expect(columns).to.deep.equal(['name', 'status']); - }); - - it('returns all columns when --extended flag is set', async () => { - const command: any = await createCommand({extended: true}); - const columns = command.getSelectedColumns(); - - expect(columns).to.include('name'); - expect(columns).to.include('status'); - expect(columns).to.include('zoneId'); - }); - - it('returns custom columns when --columns flag is set', async () => { - const command: any = await createCommand({columns: 'zoneId,name'}); - const columns = command.getSelectedColumns(); - - expect(columns).to.deep.equal(['zoneId', 'name']); - }); - - it('ignores invalid column names', async () => { - const command: any = await createCommand({columns: 'name,invalidColumn,status'}); - const columns = command.getSelectedColumns(); - - expect(columns).to.not.include('invalidColumn'); - expect(columns).to.include('name'); - expect(columns).to.include('status'); - }); - }); - describe('output formatting', () => { it('returns zones in JSON mode', async () => { const command: any = await createCommand({'tenant-id': 'zzxy_prd'}); diff --git a/packages/b2c-cli/test/commands/sandbox/list.test.ts b/packages/b2c-cli/test/commands/sandbox/list.test.ts index 49c024ac9..97b1be408 100644 --- a/packages/b2c-cli/test/commands/sandbox/list.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/list.test.ts @@ -58,46 +58,6 @@ describe('sandbox list', () => { restoreConfig(); }); - describe('getSelectedColumns', () => { - it('should return default columns when no flags provided', () => { - const command = new SandboxList([], {} as any); - (command as any).flags = {}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.deep.equal(['realm', 'instance', 'state', 'profile', 'created', 'eol', 'id', 'isCloned']); - }); - - it('should return all columns when --extended flag is set', () => { - const command = new SandboxList([], {} as any); - (command as any).flags = {extended: true}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.include('realm'); - expect(columns).to.include('hostname'); - expect(columns).to.include('createdBy'); - expect(columns).to.include('autoScheduled'); - expect(columns).to.include('isCloned'); - }); - - it('should return custom columns when --columns flag is set', () => { - const command = new SandboxList([], {} as any); - (command as any).flags = {columns: 'id,state,hostname'}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.deep.equal(['id', 'state', 'hostname']); - }); - - it('should ignore invalid column names', () => { - const command = new SandboxList([], {} as any); - (command as any).flags = {columns: 'id,invalid,state'}; - const columns = (command as any).getSelectedColumns(); - - expect(columns).to.not.include('invalid'); - expect(columns).to.include('id'); - expect(columns).to.include('state'); - }); - }); - describe('isCloned column formatting', () => { const getIsCloned = COLUMNS.isCloned.get; diff --git a/packages/b2c-cli/test/commands/scapi/replications/list.test.ts b/packages/b2c-cli/test/commands/scapi/replications/list.test.ts index d26dc2409..ca1f4333c 100644 --- a/packages/b2c-cli/test/commands/scapi/replications/list.test.ts +++ b/packages/b2c-cli/test/commands/scapi/replications/list.test.ts @@ -150,44 +150,4 @@ describe('scapi replications list', () => { } }); }); - - describe('getSelectedColumns', () => { - let config: Config; - - beforeEach(async () => { - config = await Config.load(); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('returns default columns when no flags provided', async () => { - const command: any = new ReplicationsList([], config); - stubParse(command, {}, {}); - await command.init(); - - expect(command.getSelectedColumns()).to.deep.equal(['id', 'status', 'entityType', 'entityId', 'startTime']); - }); - - it('filters invalid column names and warns when none valid', async () => { - const command: any = new ReplicationsList([], config); - stubParse(command, {columns: 'nope,bad'}, {}); - await command.init(); - - const warn = sinon.stub(command, 'warn').returns(void 0); - const columns = command.getSelectedColumns(); - - expect(columns).to.deep.equal(['id', 'status', 'entityType', 'entityId', 'startTime']); - expect(warn.calledOnce).to.equal(true); - }); - - it('returns only valid columns from --columns', async () => { - const command: any = new ReplicationsList([], config); - stubParse(command, {columns: 'id,status,bogus'}, {}); - await command.init(); - - expect(command.getSelectedColumns()).to.deep.equal(['id', 'status']); - }); - }); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 12d51ae11..f798c6512 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -167,6 +167,17 @@ "default": "./dist/cjs/operations/bm-roles/index.js" } }, + "./operations/bm-users": { + "development": "./src/operations/bm-users/index.ts", + "import": { + "types": "./dist/esm/operations/bm-users/index.d.ts", + "default": "./dist/esm/operations/bm-users/index.js" + }, + "require": { + "types": "./dist/cjs/operations/bm-users/index.d.ts", + "default": "./dist/cjs/operations/bm-users/index.js" + } + }, "./operations/sites": { "development": "./src/operations/sites/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/cli/columns.ts b/packages/b2c-tooling-sdk/src/cli/columns.ts new file mode 100644 index 000000000..290120a13 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/columns.ts @@ -0,0 +1,172 @@ +/* + * 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 `--columns` / `--extended` flag pair and selection helper for list + * and search commands. + * + * Most list-style commands in the CLI render a {@link TableRenderer | table} + * with a curated default set of columns and allow callers to override or + * expand that set. This module provides the canonical implementation so each + * command does not have to redeclare the flags or copy the selection logic. + * + * ## Usage + * + * ```typescript + * import {Flags} from '@oclif/core'; + * import { + * InstanceCommand, + * TableRenderer, + * columnFlagsFor, + * selectColumns, + * type ColumnDef, + * } from '@salesforce/b2c-tooling-sdk/cli'; + * + * const COLUMNS: Record> = { ... }; + * const DEFAULT_COLUMNS = ['id', 'name']; + * const tableRenderer = new TableRenderer(COLUMNS); + * + * export default class MyList extends InstanceCommand { + * static flags = { + * ...columnFlagsFor(COLUMNS), + * // ...other flags + * }; + * + * async run(): Promise { + * // ... + * tableRenderer.render(items, selectColumns(this, tableRenderer, DEFAULT_COLUMNS)); + * } + * } + * ``` + * + * @module cli/columns + */ +import {Flags, type Interfaces} from '@oclif/core'; +import type {TableRenderer} from './table.js'; + +type FlagChar = Interfaces.AlphabetLowercase | Interfaces.AlphabetUppercase; + +/** + * Subset of an oclif command's `flags` shape consumed by {@link selectColumns}. + * Use {@link columnFlagsFor} to populate these consistently. + */ +export interface ColumnFlags { + columns?: string; + extended?: boolean; +} + +/** + * Function that emits a non-fatal warning. Pass `this.warn.bind(this)` when + * calling from a command, or any plain logger callback otherwise. + */ +export type WarnFn = (message: string) => void; + +/** + * Options for {@link columnFlagsFor}. + */ +export interface ColumnFlagsOptions { + /** + * Short flag character for `--columns`. Defaults to `'c'`. Set to `false` + * to omit the short flag (use this when `-c` is already taken by another + * command-specific flag like `--category`). + */ + columnsChar?: FlagChar | false; + /** + * Short flag character for `--extended`. Defaults to `'x'`. Set to `false` + * to omit the short flag. + */ + extendedChar?: FlagChar | false; +} + +/** + * Returns the canonical `--columns` / `--extended` flag pair for a list-style + * command. The `--columns` flag advertises the available column keys (taken + * from the supplied `columns` map) so they show up in `--help` output. + * + * @param columns - Column definitions, used to enumerate available keys in the + * help text. + * @param options - Optional overrides for the short flag characters when they + * conflict with command-specific flags. + * @returns A flag definition object suitable for spreading into a command's + * `static flags`. + * + * @example + * ```typescript + * static flags = { + * ...columnFlagsFor(COLUMNS), + * count: Flags.integer({char: 'n', description: '...'}), + * }; + * + * // When -c is already used (e.g. for --category): + * static flags = { + * category: Flags.string({char: 'c', description: '...'}), + * ...columnFlagsFor(COLUMNS, {columnsChar: false}), + * }; + * ``` + */ +export function columnFlagsFor(columns: Record, options: ColumnFlagsOptions = {}) { + const columnsChar = options.columnsChar ?? 'c'; + const extendedChar = options.extendedChar ?? 'x'; + return { + columns: Flags.string({ + ...(columnsChar === false ? {} : {char: columnsChar}), + description: `Columns to display (comma-separated). Available: ${Object.keys(columns).join(', ')}`, + }), + extended: Flags.boolean({ + ...(extendedChar === false ? {} : {char: extendedChar}), + description: 'Show all columns including extended fields', + default: false, + }), + }; +} + +/** + * Resolves which columns a list-style command should render. Mirrors the + * pattern duplicated across all `*List` commands: + * + * - If `flags.columns === 'id,name'` is provided, validate against the + * renderer's known keys; warn (and fall back to defaults) when no valid keys + * remain. + * - If `flags.extended` is set, return every column key (including those + * marked `extended: true`). + * - Otherwise, return the supplied default set. + * + * @typeParam T - Row type (carried through the renderer). + * @param flags - The command's parsed `--columns` / `--extended` values. The + * parameter accepts the wider {@link ColumnFlags} shape, so callers can pass + * `this.flags` directly even when the inferred type contains additional + * fields. + * @param renderer - The {@link TableRenderer} backing the table — used for + * key validation and to enumerate keys in extended mode. + * @param defaults - Fallback set returned when neither flag overrides the + * selection. + * @param warn - Optional warn callback invoked when `--columns` is provided + * but no key validates; defaults to `console.warn`. + * @returns Ordered list of column keys to render. + */ +export function selectColumns( + flags: ColumnFlags, + renderer: TableRenderer, + defaults: string[], + warn: WarnFn = console.warn, +): string[] { + const {columns: columnsFlag, extended} = flags; + + if (columnsFlag) { + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = renderer.validateColumnKeys(requested); + if (valid.length === 0) { + warn(`No valid columns specified. Available: ${renderer.getColumnKeys().join(', ')}`); + return defaults; + } + return valid; + } + + if (extended) { + return renderer.getColumnKeys(); + } + + return defaults; +} diff --git a/packages/b2c-tooling-sdk/src/cli/details.ts b/packages/b2c-tooling-sdk/src/cli/details.ts new file mode 100644 index 000000000..f80063463 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/details.ts @@ -0,0 +1,122 @@ +/* + * 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 "label / value" detail block printer for `*Get` style commands. + * + * Many commands render the same two-column "Title / dashes / label-value + * pairs" block via cliui. This helper centralizes the layout so individual + * commands only need to provide the title and field list. + * + * @module cli/details + */ +import {ux} from '@oclif/core'; +import cliui from 'cliui'; + +/** + * A single label / value pair. Tuple form is supported as a shorthand — + * `['Login', 'user@example.com']` is equivalent to `{label: 'Login', value: 'user@example.com'}`. + * + * Values that are `undefined` are skipped entirely (the row is not rendered). + * `null` is rendered as the literal string "null" — coerce to `undefined` if + * you want it suppressed instead. + */ +export type DetailField = [label: string, value: string | number | boolean | undefined] | DetailFieldObject; + +/** + * Object form of a {@link DetailField} entry. + */ +export interface DetailFieldObject { + label: string; + value: string | number | boolean | undefined; +} + +/** + * Section under a sub-heading. Renders a blank line, the heading, a separator, + * and then either label/value `fields` or a flat list of `lines`. + */ +export interface DetailSection { + /** Section heading shown above the body */ + title: string; + /** Label/value rows. Mutually exclusive with `lines`. */ + fields?: DetailField[]; + /** Flat list of single-column rows (e.g. role names). Mutually exclusive with `fields`. */ + lines?: string[]; +} + +/** + * Options for {@link printFieldsBlock}. + */ +export interface PrintFieldsBlockOptions { + /** Width of the label column (default 25) */ + labelWidth?: number; + /** Width of the separator dashes (default 50) */ + separatorWidth?: number; + /** Total terminal width (default `process.stdout.columns || 80`) */ + termWidth?: number; + /** Optional sections rendered after the primary fields */ + sections?: DetailSection[]; +} + +function normalizeField(field: DetailField): DetailFieldObject { + return Array.isArray(field) ? {label: field[0], value: field[1]} : field; +} + +function renderFields(ui: ReturnType, fields: DetailField[], labelWidth: number): void { + for (const raw of fields) { + const {label, value} = normalizeField(raw); + if (value === undefined) continue; + ui.div({text: `${label}:`, width: labelWidth, padding: [0, 2, 0, 0]}, {text: String(value), padding: [0, 0, 0, 0]}); + } +} + +/** + * Renders a "details" block to stdout: a title, a separator, then a column + * of `label: value` rows. Optional named sections can be rendered after the + * primary block. + * + * @param title - Heading rendered above the fields. + * @param fields - Primary list of label / value rows. Rows whose value is + * `undefined` are skipped. + * @param options - Rendering options including optional sub-sections. + * + * @example + * ```typescript + * printFieldsBlock('User Details', [ + * ['Login', user.login], + * ['Email', user.email], + * ['Disabled', user.disabled?.toString()], + * ], { + * sections: user.roles?.length + * ? [{title: 'Roles', fields: user.roles.map((r) => [r, ''] as DetailField)}] + * : [], + * }); + * ``` + */ +export function printFieldsBlock(title: string, fields: DetailField[], options: PrintFieldsBlockOptions = {}): void { + const labelWidth = options.labelWidth ?? 25; + const separatorWidth = options.separatorWidth ?? 50; + const termWidth = options.termWidth ?? process.stdout.columns ?? 80; + + const ui = cliui({width: termWidth}); + + ui.div({text: title, padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(separatorWidth), padding: [0, 0, 0, 0]}); + renderFields(ui, fields, labelWidth); + + for (const section of options.sections ?? []) { + ui.div({text: '', padding: [1, 0, 0, 0]}); + ui.div({text: section.title, padding: [0, 0, 0, 0]}); + ui.div({text: '─'.repeat(separatorWidth), padding: [0, 0, 0, 0]}); + if (section.fields) { + renderFields(ui, section.fields, labelWidth); + } + for (const line of section.lines ?? []) { + ui.div({text: line, padding: [0, 0, 0, 0]}); + } + } + + ux.stdout(ui.toString()); +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index f25484c5f..3bce146eb 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -140,3 +140,7 @@ export {} from './hooks.js'; // Table rendering utilities export {TableRenderer, createTable} from './table.js'; export type {ColumnDef, TableRenderOptions} from './table.js'; +export {columnFlagsFor, selectColumns} from './columns.js'; +export type {ColumnFlags, ColumnFlagsOptions, WarnFn} from './columns.js'; +export {printFieldsBlock} from './details.js'; +export type {DetailField, DetailFieldObject, DetailSection, PrintFieldsBlockOptions} from './details.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts index 82cc54c78..7c9a29637 100644 --- a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts @@ -132,7 +132,14 @@ export abstract class OAuthCommand extends BaseCommand /** * Gets the default authentication methods in priority order. * This method is used by getOAuthStrategy() when no auth methods are specified in config. - * Subclasses can override this to change the default priority. + * Subclasses can override this to change the default priority — for example, + * commands that talk to endpoints requiring a real user identity should + * return `['implicit']` so that user-auth is preferred when the user has + * not explicitly chosen an auth method. + * + * Explicit user input via `--auth-methods`, `--client-secret`, `--jwt-cert`, + * etc. always wins over the default; this method only changes what happens + * when the user has not specified anything. * * @returns Array of auth methods in priority order (first is highest priority) */ diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts new file mode 100644 index 000000000..bff4306e2 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.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 + */ +/** + * Business Manager user operations for B2C Commerce instances. + * + * Provides functions for querying and managing instance-level users via OCAPI Data API. + * These are distinct from Account Manager users managed via {@link @salesforce/b2c-tooling-sdk/operations/users | operations/users}. + * + * On instances using SSO with Account Manager (the default for production), creating local + * BM users via the Data API is rejected with `LocalUserCreationException`. These operations + * focus on read/search/lifecycle of AM-managed users plus access-key administration. + * + * ## Core User Functions + * + * - {@link listBmUsers} - List all users on an instance + * - {@link getBmUser} - Get a user by login + * - {@link whoamiBmUser} - Get the currently authenticated user + * - {@link searchBmUsers} - Search users with filter expressions + * - {@link updateBmUser} - Update user attributes (locale, external_id, disabled) + * - {@link deleteBmUser} - Delete a user from the instance + * + * ## Access Keys (externally-managed users) + * + * - {@link getBmUserAccessKey} - Read access key details + * - {@link createBmUserAccessKey} - Create / rotate an access key + * - {@link setBmUserAccessKeyEnabled} - Enable / disable an access key + * - {@link deleteBmUserAccessKey} - Delete an access key + * + * ## Usage + * + * ```typescript + * import {listBmUsers, searchBmUsers, createBmUserAccessKey} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; + * import {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; + * + * const config = resolveConfig(); + * const instance = config.createB2CInstance(); + * + * // List all users + * const users = await listBmUsers(instance); + * + * // Search for locked users + * const locked = await searchBmUsers(instance, {locked: true}); + * + * // Provision a WebDAV access key for a user + * const key = await createBmUserAccessKey(instance, 'user@example.com', 'WEBDAV'); + * console.log(key.access_key); // Only returned at creation time + * ``` + * + * @module operations/bm-users + */ +export { + listBmUsers, + getBmUser, + whoamiBmUser, + searchBmUsers, + updateBmUser, + deleteBmUser, + getBmUserAccessKey, + createBmUserAccessKey, + setBmUserAccessKeyEnabled, + deleteBmUserAccessKey, +} from './users.js'; + +export type { + BmUser, + BmUsers, + BmUserSearchResult, + BmAccessKeyDetails, + ListBmUsersOptions, + SearchBmUsersOptions, + UpdateBmUserChanges, +} from './users.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts new file mode 100644 index 000000000..36f250ecd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts @@ -0,0 +1,370 @@ +/* + * 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 user operations for B2C Commerce instances. + * + * Provides functions for querying and managing instance-level users via OCAPI Data API. + * + * Note: Most production B2C Commerce instances delegate user identity to Account Manager + * (SSO), so creating local Business Manager users via the Data API typically fails with + * `LocalUserCreationException`. These operations focus on read/search/lifecycle of + * AM-managed users plus access-key administration. + */ +import type {B2CInstance} from '../../instance/index.js'; +import type {components} from '../../clients/ocapi.generated.js'; +import {getApiErrorMessage} from '../../clients/error-utils.js'; + +/** + * BM user from OCAPI. + */ +export type BmUser = components['schemas']['user']; + +/** + * BM users collection from OCAPI. + */ +export type BmUsers = components['schemas']['users']; + +/** + * BM user search result from OCAPI. + */ +export type BmUserSearchResult = components['schemas']['user_search_result']; + +/** + * Access key details for an externally-managed user. + */ +export type BmAccessKeyDetails = components['schemas']['access_key_details']; + +/** + * Updatable user fields for `patch` operations. + * + * Note: `locked` and `password` cannot be modified via PATCH per the API spec. + */ +export interface UpdateBmUserChanges { + disabled?: boolean; + email?: string; + external_id?: string; + first_name?: string; + last_name?: string; + preferred_data_locale?: string; + preferred_ui_locale?: string; +} + +/** + * Options for listing BM users. + */ +export interface ListBmUsersOptions { + /** Start index (default 0) */ + start?: number; + /** Number of items to return (default 25) */ + count?: number; + /** Property selector (default returns shallow user fields) */ + select?: string; +} + +/** + * Options for searching BM users. + * + * Searchable fields per the Data API spec: login, email, first_name, last_name, + * external_id, last_login_date, is_locked, is_disabled. + */ +export interface SearchBmUsersOptions { + /** + * Pre-built OCAPI query object (e.g. `{text_query: {fields: ['login'], search_phrase: 'foo'}}`). + * If omitted, one is built from the convenience flags below. + */ + query?: unknown; + /** Free-text phrase searched across login/email/first_name/last_name */ + searchPhrase?: string; + /** Match users with a specific login */ + login?: string; + /** Match users with a specific email */ + email?: string; + /** Match locked users */ + locked?: boolean; + /** Match disabled users */ + disabled?: boolean; + /** Field to sort by (e.g. 'login', 'email', 'last_login_date') */ + sortBy?: string; + /** Sort direction */ + sortOrder?: 'asc' | 'desc'; + /** Start index (default 0) */ + start?: number; + /** Number of items to return (default 25) */ + count?: number; +} + +/** + * Lists all users on a B2C Commerce instance. + * + * @param instance - B2C instance to query + * @param options - Pagination options + * @returns Users collection with pagination info + */ +export async function listBmUsers(instance: B2CInstance, options: ListBmUsersOptions = {}): Promise { + const {start, count, select} = options; + + const {data, error, response} = await instance.ocapi.GET('/users', { + params: {query: {start, count, select: select ?? '(**)'}}, + }); + + if (error) { + throw new Error(`Failed to list users: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmUsers; +} + +/** + * Gets a single user by login (email). + * + * @param instance - B2C instance + * @param login - User login + * @returns User details + */ +export async function getBmUser(instance: B2CInstance, login: string): Promise { + const {data, error, response} = await instance.ocapi.GET('/users/{login}', { + params: {path: {login}}, + }); + + if (error) { + throw new Error(`Failed to get user ${login}: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmUser; +} + +/** + * Returns details for the currently authenticated user (the user the OAuth token resolves to). + * + * Useful for verifying which BM identity is in use on an instance. + * + * @param instance - B2C instance + * @returns Current user details (includes password expiration info) + */ +export async function whoamiBmUser(instance: B2CInstance): Promise { + const {data, error, response} = await instance.ocapi.GET('/users/this'); + + if (error) { + throw new Error(`Failed to get current user: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmUser; +} + +/** + * Updates an existing user. The `locked` flag and the user `password` cannot be updated + * with this resource. + * + * @param instance - B2C instance + * @param login - User login + * @param changes - Fields to update + * @returns Updated user + */ +export async function updateBmUser( + instance: B2CInstance, + login: string, + changes: UpdateBmUserChanges, +): Promise { + const {data, error, response} = await instance.ocapi.PATCH('/users/{login}', { + params: {path: {login}}, + body: changes as components['schemas']['user'], + }); + + if (error) { + throw new Error(`Failed to update user ${login}: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmUser; +} + +/** + * Deletes a user from an instance. + * + * @param instance - B2C instance + * @param login - User login + */ +export async function deleteBmUser(instance: B2CInstance, login: string): Promise { + const {error, response} = await instance.ocapi.DELETE('/users/{login}', { + params: {path: {login}}, + }); + + if (error) { + throw new Error(`Failed to delete user ${login}: ${getApiErrorMessage(error, response)}`, {cause: error}); + } +} + +/** + * Searches users on an instance. + * + * Supports either a fully-formed OCAPI query (`options.query`) or convenience flags + * (`searchPhrase`, `login`, `email`, `locked`, `disabled`) which are combined into a + * `bool_query`. If no criteria are provided a `match_all_query` is used. + * + * @param instance - B2C instance + * @param options - Search options + * @returns User search result + */ +export async function searchBmUsers( + instance: B2CInstance, + options: SearchBmUsersOptions = {}, +): Promise { + const {query: providedQuery, searchPhrase, login, email, locked, disabled, sortBy, sortOrder, start, count} = options; + + let query: unknown = providedQuery; + if (!query) { + const queries: unknown[] = []; + + if (searchPhrase) { + queries.push({ + text_query: { + fields: ['login', 'email', 'first_name', 'last_name'], + search_phrase: searchPhrase, + }, + }); + } + if (login) { + queries.push({term_query: {fields: ['login'], operator: 'is', values: [login]}}); + } + if (email) { + queries.push({term_query: {fields: ['email'], operator: 'is', values: [email]}}); + } + if (locked !== undefined) { + queries.push({term_query: {fields: ['is_locked'], operator: 'is', values: [locked]}}); + } + if (disabled !== undefined) { + queries.push({term_query: {fields: ['is_disabled'], operator: 'is', values: [disabled]}}); + } + + if (queries.length === 0) { + query = {match_all_query: {}}; + } else if (queries.length === 1) { + query = queries[0]; + } else { + query = {bool_query: {must: queries}}; + } + } + + const sorts = sortBy ? [{field: sortBy, sort_order: sortOrder ?? 'asc'}] : undefined; + + const {data, error, response} = await instance.ocapi.POST('/user_search', { + body: { + query, + start, + count, + sorts, + } as unknown as components['schemas']['search_request'], + }); + + if (error) { + throw new Error(`Failed to search users: ${getApiErrorMessage(error, response)}`, {cause: error}); + } + + return data as BmUserSearchResult; +} + +/** + * Gets a single access key for an externally-managed user. + * + * @param instance - B2C instance + * @param login - User login + * @param scope - Access key scope (e.g. 'WEBDAV', 'OCAPI', 'SCAPI') + * @returns Access key details + */ +export async function getBmUserAccessKey( + instance: B2CInstance, + login: string, + scope: string, +): Promise { + const {data, error, response} = await instance.ocapi.GET('/users/{login}/access_key/{scope}', { + params: {path: {login, scope}}, + }); + + if (error) { + throw new Error(`Failed to get access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } + + return data as BmAccessKeyDetails; +} + +/** + * Creates a single access key for an externally-managed user (replaces any existing key + * for the same scope). + * + * The returned object includes the newly-generated `access_key` value — this is the only + * time it is returned, so callers should record it. + * + * @param instance - B2C instance + * @param login - User login + * @param scope - Access key scope + * @returns Access key details (including the secret `access_key` value) + */ +export async function createBmUserAccessKey( + instance: B2CInstance, + login: string, + scope: string, +): Promise { + const {data, error, response} = await instance.ocapi.PUT('/users/{login}/access_key/{scope}', { + params: {path: {login, scope}}, + }); + + if (error) { + throw new Error(`Failed to create access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } + + return data as BmAccessKeyDetails; +} + +/** + * Enables or disables an existing access key for an externally-managed user. + * + * @param instance - B2C instance + * @param login - User login + * @param scope - Access key scope + * @param enabled - Whether the access key should be enabled + * @returns Updated access key details + */ +export async function setBmUserAccessKeyEnabled( + instance: B2CInstance, + login: string, + scope: string, + enabled: boolean, +): Promise { + const {data, error, response} = await instance.ocapi.PATCH('/users/{login}/access_key/{scope}', { + params: {path: {login, scope}}, + body: {enabled} as components['schemas']['access_key_update_request'], + }); + + if (error) { + throw new Error(`Failed to update access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } + + return data as BmAccessKeyDetails; +} + +/** + * Deletes a single access key for an externally-managed user. + * + * @param instance - B2C instance + * @param login - User login + * @param scope - Access key scope + */ +export async function deleteBmUserAccessKey(instance: B2CInstance, login: string, scope: string): Promise { + const {error, response} = await instance.ocapi.DELETE('/users/{login}/access_key/{scope}', { + params: {path: {login, scope}}, + }); + + if (error) { + throw new Error(`Failed to delete access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { + cause: error, + }); + } +} diff --git a/skills/b2c-cli/skills/b2c-am/SKILL.md b/skills/b2c-cli/skills/b2c-am/SKILL.md index 7238408f8..cf05aa79f 100644 --- a/skills/b2c-cli/skills/b2c-am/SKILL.md +++ b/skills/b2c-cli/skills/b2c-am/SKILL.md @@ -1,6 +1,6 @@ --- name: b2c-am -description: Manage users, roles, API clients, and organizations across Account Manager and Business Manager using the b2c CLI. Use this skill whenever the user needs to create or delete users, grant or revoke roles (AM or BM), onboard or offboard developers, audit permissions, look up organizations, create or update API clients for CI/CD pipelines, or manage BM role permissions on an instance. Also use when the user asks about user administration, role assignments, or permission audits — even if they just say "add a new developer", "set up an API client", or "who has admin access". +description: Manage Account Manager resources including API clients, users, roles, and organizations. Use this skill whenever the user needs to create or update API clients, onboard or offboard developers, assign Account Manager roles scoped to tenants, audit user permissions, look up organizations, or provision API clients for CI/CD pipelines. Also use when managing AM role assignments or querying Account Manager data — even if they just say "add a new developer" or "set up an API client". For instance-level Business Manager administration (BM roles, BM users, BM access keys, BM whoami), defer to the `b2c-cli:b2c-bm-users-roles` skill. --- # B2C Account Manager Skill @@ -24,9 +24,8 @@ Account Manager commands work out of the box with no configuration. The CLI uses | AM Users & Roles | User Administrator | Account Administrator or User Administrator | | AM Organizations | Not supported -- use `--user-auth` | Account Administrator | | AM API Clients | Not supported -- use `--user-auth` | Account Administrator or API Administrator | -| BM Roles | OCAPI permissions for `/roles` resource | OCAPI permissions for `/roles` resource | -Organization and API client management are only available with user authentication. +Organization and API client management are only available with user authentication. For Business Manager administration (BM roles, users, access keys, whoami), see the `b2c-cli:b2c-bm-users-roles` skill. ## API Clients @@ -218,52 +217,16 @@ b2c am orgs get b2c am orgs get "My Organization" ``` -## Business Manager Roles +## Business Manager Administration -BM role commands operate on a specific Commerce Cloud instance (via `--server` or config). +BM-side resources (instance roles, instance users, access keys, whoami) live in the **`b2c-cli:b2c-bm-users-roles`** skill. Use it for: -```bash -# list BM roles on the configured instance -b2c bm roles list - -# target a different 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 -``` +- `b2c bm roles` — list/get/create/delete instance access roles, grant/revoke users, manage permissions +- `b2c bm users` — list, get, search, update, and delete instance users via the OCAPI `/users` resource +- `b2c bm whoami` — show the BM user the current OAuth token resolves to +- `b2c bm access-key` — provision and rotate WebDAV/OCAPI/Storefront access keys for SSO-managed users -The permissions JSON has four sections: `functional`, `module`, `locale`, and `webdav`. Each can be scoped to organization, site, or unscoped depending on type. +Defer to that skill for BM examples and patterns. AM-side onboarding flows (creating an AM user, granting AM roles scoped to tenants) stay here. ## Common Workflows diff --git a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md new file mode 100644 index 000000000..9a4848aae --- /dev/null +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -0,0 +1,195 @@ +--- +name: b2c-bm-users-roles +description: Manage Business Manager users, access roles, role permissions, and per-user access keys on a B2C Commerce instance using the b2c CLI. Use this skill whenever the user needs to list or search BM users on a sandbox or production instance, identify which BM user an OAuth token resolves to ("whoami"), assign or revoke instance-level access roles, edit role permissions, look up a user's WebDAV / OCAPI / Storefront access key, or rotate access keys for SSO-managed users. Also use when the user asks "what's my BM login on sandbox X", "rotate my WebDAV password", "how do I make a custom BM role", "audit BM users on this instance", or "delete a stale BM user from a sandbox". +--- + +# B2C Business Manager Users, Roles, and Access Keys + +Use the `b2c bm` commands to administer instance-level Business Manager resources via the OCAPI Data API. These commands target a specific Commerce Cloud instance — pass `--server`/`-s` or set the active instance in `dw.json` first. + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli bm whoami`). + +For **Account Manager** user/role/client management (cross-instance, scoped to tenants), see the `b2c-cli:b2c-am` skill instead. + +## 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. + +| Command group | Default auth | Why | +|---|---|---| +| `b2c bm roles ...` | client-credentials → jwt → implicit | OCAPI permissions for `/roles` | +| `b2c bm users {list,get,search,update,delete}` | client-credentials → jwt → implicit | OCAPI permissions for `/users` | +| `b2c bm whoami` | **implicit (browser)** | OCAPI `/users/this` requires the token to resolve to a BM user | +| `b2c bm access-key {get,create,set,delete}` | **implicit (browser)** | OCAPI access-key endpoints require "a valid user" plus `Manage_Users_Access_Keys` permission | + +Override the default with `--auth-methods client-credentials` (or `--client-secret` flags) when your service-client setup is configured to issue user-bearing tokens. + +## Business Manager Roles + +BM roles are instance-level Business Manager access roles (e.g. `Administrator`, `Support`, plus any custom roles). + +```bash +# list roles on the configured instance +b2c bm roles list + +# target a different instance +b2c bm roles list --server my-sandbox.demandware.net + +# get role details, including assigned users +b2c bm roles get Administrator --expand users + +# create a custom role +b2c bm roles create MyEditor --description "Custom role for content editors" + +# delete a custom role (system roles cannot be deleted) +b2c bm roles delete MyEditor + +# assign / unassign a user +b2c bm roles grant user@example.com --role Administrator +b2c bm roles revoke user@example.com --role Administrator + +# all commands accept --json for machine-readable output +b2c bm roles list --json +``` + +### Role Permissions + +Permissions use a file-based get/set workflow because the API replaces the entire permission set on each write. + +```bash +# view a permission summary +b2c bm roles permissions get Administrator + +# export 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 the permission type. + +## Business Manager Users + +Most production instances use SSO with Account Manager — creating *local* BM users is rejected with `LocalUserCreationException`. These commands focus on **read/search/update/delete** for AM-managed users plus the per-user access-key administration below. + +```bash +# list (default 25) +b2c bm users list +b2c bm users list --count 50 --start 50 # pagination +b2c bm users list --extended # add lastLogin, externalId +b2c bm users list --columns login,email,lastLogin # custom column set + +# get one user by login (email) +b2c bm users get user@example.com + +# search by attribute (any combination of flags) +b2c bm users search --search-phrase smith +b2c bm users search --login user@example.com +b2c bm users search --locked --sort-by last_login_date --sort-order desc +b2c bm users search --query '{"text_query":{"fields":["login"],"search_phrase":"foo"}}' + +# update non-identity fields (locale, external_id, disabled, name) +b2c bm users update user@example.com --disabled +b2c bm users update user@example.com --no-disabled --preferred-ui-locale en_US +b2c bm users update user@example.com --first-name Jane --last-name Doe + +# delete (prompts for confirmation; --force to skip) +b2c bm users delete user@example.com +b2c bm users delete user@example.com --force --json +``` + +**Cannot be updated via `update`:** the `locked` flag and the user `password` (those are governed by AM/SSO). + +## Whoami — Identify the Current BM User + +`bm whoami` calls `GET /users/this` and returns the BM user the OAuth token resolves to. Useful for verifying which identity will be used for downstream commands and for sanity-checking that a token actually carries a user claim. + +```bash +b2c bm whoami +b2c bm whoami --json +``` + +Defaults to browser-based user-auth — a fresh shell will trigger an `b2c auth login` flow. Once logged in, the saved session is reused across commands until it expires. + +## Access Keys (WebDAV, OCAPI, Storefront) + +Access keys let SSO-managed BM users authenticate to non-OAuth surfaces (WebDAV, classic OCAPI/SCAPI Basic auth, or Storefront diagnostics). Three scopes exist; pick the one matching the surface you need to use. + +| Scope | Used for | +|---|---| +| `WEBDAV_AND_STUDIO` (default) | WebDAV uploads (cartridge sync, IMPEX), Studio access | +| `AGENT_USER_AND_OCAPI` | Customer Service Center (CSC) and OCAPI Basic auth | +| `STOREFRONT` | Storefront diagnostic / agent login passwords | + +`[LOGIN]` is **optional** on every access-key command — when omitted, the CLI calls `bm whoami` first and operates on your own user. Passing an explicit login lets administrators manage someone else's keys (requires `Manage_Users_Access_Keys` permission). + +```bash +# get access-key state for the current user (defaults to WEBDAV_AND_STUDIO) +b2c bm access-key get +b2c bm access-key get --scope STOREFRONT +b2c bm access-key get user@example.com --scope AGENT_USER_AND_OCAPI + +# create or rotate an access key — the secret is shown ONCE at creation +b2c bm access-key create +b2c bm access-key create --scope STOREFRONT +b2c bm access-key create --json | jq -r '.access_key' + +# enable / disable an existing key +b2c bm access-key set --enabled +b2c bm access-key set --no-enabled +b2c bm access-key set user@example.com --scope STOREFRONT --enabled + +# delete (prompts for confirmation; --force to skip) +b2c bm access-key delete +b2c bm access-key delete --scope STOREFRONT --force +``` + +> **Important:** the `access_key` value is only returned in the response of `create`. Subsequent `get` calls do not return it. If you lose the value, run `create` again — the previous key is removed automatically. + +## Common Workflows + +### Rotate my own WebDAV password (no admin privileges needed) + +```bash +b2c bm access-key create +# record the printed access_key — it's the new password for WebDAV/IMPEX +``` + +### Audit users with admin role and stale logins + +```bash +b2c bm roles get Administrator --expand users --json | jq '.users[].login' +b2c bm users search --sort-by last_login_date --sort-order asc --json +``` + +### Provision a new custom role and assign one user + +```bash +b2c bm roles create MyEditor --description "Content editors" +b2c bm roles permissions get MyEditor --output role.json +# edit role.json +b2c bm roles permissions set MyEditor --file role.json +b2c bm roles grant editor@example.com --role MyEditor +``` + +### Cycle access keys for an SSO user (admin) + +```bash +# disable temporarily +b2c bm access-key set user@example.com --scope WEBDAV_AND_STUDIO --no-enabled + +# rotate +b2c bm access-key create user@example.com --scope WEBDAV_AND_STUDIO +``` + +## Common Patterns + +- All list/search commands support `--columns`, `--extended` / `-x`, and `--json`. +- `bm users` and `bm roles` use OCAPI pagination: `--count`/`-n` and `--start`. AM commands use `--size`/`--page` instead — don't mix them up. +- Destructive commands (`bm users delete`, `bm access-key delete`) prompt for confirmation. Use `--force` for non-interactive scripts. `--json` mode skips the prompt automatically. +- When a service client cannot resolve a BM user (e.g. AM-only credential), `whoami` and `access-key` commands return `UserNotAvailableException` from the API — re-run with `--user-auth` or `b2c auth login` first. + +### More + +See `b2c bm --help`, `b2c bm users --help`, and `b2c bm access-key --help` for the full flag list. The OCAPI Data API user resource documentation describes the underlying endpoints and their fault codes. From e2ca41ec22b4653234c802fab386635b37bf1325 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 7 May 2026 18:15:50 -0400 Subject: [PATCH 2/5] Migrate bm delete commands to canonical ux/confirm helper Switches bm/users/delete and bm/access-key/delete from @inquirer/prompts to the SDK's @salesforce/b2c-tooling-sdk/ux confirm() introduced in main. Drops the @inquirer/prompts dependency from these two files. --- .../src/commands/bm/access-key/delete.ts | 17 ++++++++--------- .../b2c-cli/src/commands/bm/users/delete.ts | 9 ++++----- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/b2c-cli/src/commands/bm/access-key/delete.ts b/packages/b2c-cli/src/commands/bm/access-key/delete.ts index 8251bcd4d..0a748b8fe 100644 --- a/packages/b2c-cli/src/commands/bm/access-key/delete.ts +++ b/packages/b2c-cli/src/commands/bm/access-key/delete.ts @@ -4,8 +4,8 @@ * 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 {confirm as promptConfirm} from '@inquirer/prompts'; import {deleteBmUserAccessKey} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {confirm} from '@salesforce/b2c-tooling-sdk/ux'; import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; import {t} from '../../../i18n/index.js'; @@ -60,14 +60,13 @@ export default class BmAccessKeyDelete extends BmUserAuthCommand const hostname = this.resolvedConfig.values.hostname!; if (!force && !this.jsonEnabled()) { - const answer = await promptConfirm({ - message: t('commands.bm.users.delete.confirm', 'Delete user {{login}} from {{hostname}}?', {login, hostname}), - default: false, - }); + const answer = await confirm( + t('commands.bm.users.delete.confirm', 'Delete user {{login}} from {{hostname}}?', {login, hostname}), + ); if (!answer) { this.log(t('commands.bm.users.delete.cancelled', 'Cancelled.')); return {success: false, login, hostname}; From 1c8e419ac900cb4cd0e87aaf6430fd81fe1d0ed3 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 7 May 2026 18:31:51 -0400 Subject: [PATCH 3/5] Audit pass: hoist scope enum, extend helpers to MRT, migrate confirms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acts on findings from a post-merge audit covering both this branch and recently-merged main work: SDK: - Export ACCESS_KEY_SCOPES + AccessKeyScope from operations/bm-users so the 4 access-key CLI commands no longer redeclare the same tuple. - Refresh stale doc comment on getBmUserAccessKey (referenced removed example values 'WEBDAV', 'OCAPI', 'SCAPI'). - printFieldsBlock now accepts null in addition to undefined and skips both, matching the common shape of optional OpenAPI fields. New DetailValue type alias exported. CLI — apply our helpers to MRT commands main introduced in PR #407: - mrt/org/cert/list and mrt/org/member/list now use TableRenderer + columnFlagsFor + selectColumns instead of inline createTable. - mrt/org/cert/get and mrt/org/member/get now use printFieldsBlock instead of inline cliui label/value rendering. CLI — finish the @inquirer/prompts -> SDK ux migration begun in main: - setup, setup/instance/remove, sandbox/reset, sandbox/alias/delete, and mrt/env/var/push now use confirm() from @salesforce/b2c-tooling-sdk/ux. The two remaining @inquirer/prompts importers (setup/skills, setup/instance/create) need more than just confirm so they stay as-is for now. --- .../src/commands/bm/access-key/create.ts | 8 +++--- .../src/commands/bm/access-key/delete.ts | 4 +-- .../b2c-cli/src/commands/bm/access-key/get.ts | 8 +++--- .../b2c-cli/src/commands/bm/access-key/set.ts | 8 +++--- .../b2c-cli/src/commands/mrt/env/var/push.ts | 4 +-- .../b2c-cli/src/commands/mrt/org/cert/get.ts | 27 +++++++++---------- .../b2c-cli/src/commands/mrt/org/cert/list.ts | 16 +++++++++-- .../src/commands/mrt/org/member/get.ts | 24 +++++++---------- .../src/commands/mrt/org/member/list.ts | 16 +++++++++-- .../src/commands/sandbox/alias/delete.ts | 9 +++---- .../b2c-cli/src/commands/sandbox/reset.ts | 9 +++---- packages/b2c-cli/src/commands/setup/index.ts | 7 ++--- .../src/commands/setup/instance/remove.ts | 7 ++--- packages/b2c-tooling-sdk/src/cli/details.ts | 17 +++++++----- packages/b2c-tooling-sdk/src/cli/index.ts | 2 +- .../src/operations/bm-users/index.ts | 2 ++ .../src/operations/bm-users/users.ts | 13 ++++++++- 17 files changed, 105 insertions(+), 76 deletions(-) diff --git a/packages/b2c-cli/src/commands/bm/access-key/create.ts b/packages/b2c-cli/src/commands/bm/access-key/create.ts index cffee15db..d03312791 100644 --- a/packages/b2c-cli/src/commands/bm/access-key/create.ts +++ b/packages/b2c-cli/src/commands/bm/access-key/create.ts @@ -5,13 +5,15 @@ */ import {Args, Flags} from '@oclif/core'; import {printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; -import {createBmUserAccessKey, type BmAccessKeyDetails} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import { + ACCESS_KEY_SCOPES, + createBmUserAccessKey, + type BmAccessKeyDetails, +} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; import {t} from '../../../i18n/index.js'; -const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; - export default class BmAccessKeyCreate extends BmUserAuthCommand { static args = { login: Args.string({ diff --git a/packages/b2c-cli/src/commands/bm/access-key/delete.ts b/packages/b2c-cli/src/commands/bm/access-key/delete.ts index 0a748b8fe..9af313b31 100644 --- a/packages/b2c-cli/src/commands/bm/access-key/delete.ts +++ b/packages/b2c-cli/src/commands/bm/access-key/delete.ts @@ -4,14 +4,12 @@ * 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 {deleteBmUserAccessKey} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {ACCESS_KEY_SCOPES, deleteBmUserAccessKey} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {confirm} from '@salesforce/b2c-tooling-sdk/ux'; import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; import {t} from '../../../i18n/index.js'; -const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; - interface DeleteResult { success: boolean; login: string; diff --git a/packages/b2c-cli/src/commands/bm/access-key/get.ts b/packages/b2c-cli/src/commands/bm/access-key/get.ts index b57341553..673bd4675 100644 --- a/packages/b2c-cli/src/commands/bm/access-key/get.ts +++ b/packages/b2c-cli/src/commands/bm/access-key/get.ts @@ -5,13 +5,15 @@ */ import {Args, Flags} from '@oclif/core'; import {printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; -import {getBmUserAccessKey, type BmAccessKeyDetails} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import { + ACCESS_KEY_SCOPES, + getBmUserAccessKey, + type BmAccessKeyDetails, +} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; import {t} from '../../../i18n/index.js'; -const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; - export default class BmAccessKeyGet extends BmUserAuthCommand { static args = { login: Args.string({ diff --git a/packages/b2c-cli/src/commands/bm/access-key/set.ts b/packages/b2c-cli/src/commands/bm/access-key/set.ts index eb879fde4..0498fe6fa 100644 --- a/packages/b2c-cli/src/commands/bm/access-key/set.ts +++ b/packages/b2c-cli/src/commands/bm/access-key/set.ts @@ -4,13 +4,15 @@ * 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 {setBmUserAccessKeyEnabled, type BmAccessKeyDetails} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import { + ACCESS_KEY_SCOPES, + setBmUserAccessKeyEnabled, + type BmAccessKeyDetails, +} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {BmUserAuthCommand} from '../../../utils/bm/user-auth-command.js'; import {resolveLoginOrWhoami} from '../../../utils/bm/resolve-login.js'; import {t} from '../../../i18n/index.js'; -const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; - export default class BmAccessKeySet extends BmUserAuthCommand { static args = { login: Args.string({ diff --git a/packages/b2c-cli/src/commands/mrt/env/var/push.ts b/packages/b2c-cli/src/commands/mrt/env/var/push.ts index 30d02c3d7..5de1ddaab 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/push.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/push.ts @@ -7,7 +7,7 @@ import {readFileSync} from 'node:fs'; import {resolve} from 'node:path'; import {parseEnv} from 'node:util'; import {Flags, ux} from '@oclif/core'; -import {confirm} from '@inquirer/prompts'; +import {confirm} from '@salesforce/b2c-tooling-sdk/ux'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {listEnvVars, setEnvVar, setEnvVars} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -149,7 +149,7 @@ export default class MrtEnvVarPush extends MrtCommand { environment, }, ); - const confirmed = await confirm({message, default: false}); + const confirmed = await confirm(message); if (!confirmed) { ux.stdout(t('commands.mrt.env.var.push.aborted', 'Aborted.')); return {pushed: 0, failed: 0, skipped: diff.remoteOnly.length}; diff --git a/packages/b2c-cli/src/commands/mrt/org/cert/get.ts b/packages/b2c-cli/src/commands/mrt/org/cert/get.ts index 84a1d0da7..a4a98f932 100644 --- a/packages/b2c-cli/src/commands/mrt/org/cert/get.ts +++ b/packages/b2c-cli/src/commands/mrt/org/cert/get.ts @@ -3,9 +3,8 @@ * 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 {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {Args, Flags} from '@oclif/core'; +import {MrtCommand, printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; import {getCertificate, type MrtCertificate} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; @@ -40,18 +39,16 @@ export default class MrtOrgCertGet extends MrtCommand { ); if (!this.jsonEnabled()) { - const ui = cliui({width: process.stdout.columns || 80}); - const w = 24; - ui.div(''); - ui.div({text: 'ID:', width: w}, {text: cert.id?.toString() ?? '-'}); - ui.div({text: 'Domain:', width: w}, {text: cert.domain_name ?? '-'}); - ui.div({text: 'Validation Status:', width: w}, {text: cert.validation_status ?? '-'}); - ui.div({text: 'Validation Record:', width: w}, {text: cert.validation_record ?? '-'}); - ui.div({text: 'Validation Requested:', width: w}, {text: cert.validation_requested_at ?? '-'}); - ui.div({text: 'Expires:', width: w}, {text: cert.expires_at ?? '-'}); - ui.div({text: 'Renewal Status:', width: w}, {text: (cert.renewal_status as null | string) ?? '-'}); - ui.div({text: 'Renewal Eligibility:', width: w}, {text: (cert.renewal_eligibility as null | string) ?? '-'}); - ux.stdout(ui.toString()); + printFieldsBlock('Certificate', [ + ['ID', cert.id?.toString()], + ['Domain', cert.domain_name], + ['Validation Status', cert.validation_status], + ['Validation Record', cert.validation_record], + ['Validation Requested', cert.validation_requested_at], + ['Expires', cert.expires_at], + ['Renewal Status', cert.renewal_status as null | string | undefined], + ['Renewal Eligibility', cert.renewal_eligibility as null | string | undefined], + ]); } return cert; diff --git a/packages/b2c-cli/src/commands/mrt/org/cert/list.ts b/packages/b2c-cli/src/commands/mrt/org/cert/list.ts index d280182d5..adb90c6dc 100644 --- a/packages/b2c-cli/src/commands/mrt/org/cert/list.ts +++ b/packages/b2c-cli/src/commands/mrt/org/cert/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listCertificates, type ListCertificatesResult, @@ -25,6 +31,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['id', 'domain', 'validation', 'expires', 'renewal']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class MrtOrgCertList extends MrtCommand { static description = withDocs( t('commands.mrt.org.cert.list.description', 'List custom domain certificates for an organization'), @@ -45,6 +53,7 @@ export default class MrtOrgCertList extends MrtCommand { offset: Flags.integer({description: 'Offset for pagination'}), search: Flags.string({description: 'Search term for filtering'}), 'custom-only': Flags.boolean({description: 'Show only customer-managed certificates'}), + ...columnFlagsFor(COLUMNS), }; async run(): Promise { @@ -69,7 +78,10 @@ export default class MrtOrgCertList extends MrtCommand { this.log(t('commands.mrt.org.cert.list.empty', 'No certificates found.')); } else { this.log(t('commands.mrt.org.cert.list.count', 'Found {{count}} certificate(s):', {count: result.count})); - createTable(COLUMNS).render(result.certificates, DEFAULT_COLUMNS); + tableRenderer.render( + result.certificates, + selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), + ); } } diff --git a/packages/b2c-cli/src/commands/mrt/org/member/get.ts b/packages/b2c-cli/src/commands/mrt/org/member/get.ts index b2eaf585e..8212f63cb 100644 --- a/packages/b2c-cli/src/commands/mrt/org/member/get.ts +++ b/packages/b2c-cli/src/commands/mrt/org/member/get.ts @@ -3,9 +3,8 @@ * 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 {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {Args, Flags} from '@oclif/core'; +import {MrtCommand, printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; import { getOrgMember, ORG_ROLES, @@ -45,18 +44,13 @@ export default class MrtOrgMemberGet extends MrtCommand ); if (!this.jsonEnabled()) { - const ui = cliui({width: process.stdout.columns || 80}); - const w = 22; - ui.div(''); - ui.div({text: 'Email:', width: w}, {text: member.email ?? member.user ?? '-'}); - ui.div({text: 'Name:', width: w}, {text: [member.first_name, member.last_name].filter(Boolean).join(' ') || '-'}); - ui.div({text: 'Role:', width: w}, {text: ORG_ROLES[member.role as OrgRoleValue] ?? String(member.role)}); - ui.div({text: 'View All Projects:', width: w}, {text: member.can_view_all_projects ? 'Yes' : 'No'}); - ui.div( - {text: 'Cert Permission:', width: w}, - {text: member.custom_domain_cert_permission === 2 ? 'Enabled' : 'Disabled'}, - ); - ux.stdout(ui.toString()); + printFieldsBlock('Member', [ + ['Email', member.email ?? member.user ?? undefined], + ['Name', [member.first_name, member.last_name].filter(Boolean).join(' ') || undefined], + ['Role', ORG_ROLES[member.role as OrgRoleValue] ?? String(member.role)], + ['View All Projects', member.can_view_all_projects ? 'Yes' : 'No'], + ['Cert Permission', member.custom_domain_cert_permission === 2 ? 'Enabled' : 'Disabled'], + ]); } return member; diff --git a/packages/b2c-cli/src/commands/mrt/org/member/list.ts b/packages/b2c-cli/src/commands/mrt/org/member/list.ts index 1e207b750..ac5971882 100644 --- a/packages/b2c-cli/src/commands/mrt/org/member/list.ts +++ b/packages/b2c-cli/src/commands/mrt/org/member/list.ts @@ -4,7 +4,13 @@ * 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 {MrtCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + MrtCommand, + TableRenderer, + columnFlagsFor, + selectColumns, + type ColumnDef, +} from '@salesforce/b2c-tooling-sdk/cli'; import { listOrgMembers, ORG_ROLES, @@ -39,6 +45,8 @@ const COLUMNS: Record> = { const DEFAULT_COLUMNS = ['email', 'name', 'role', 'allProjects', 'certPerm']; +const tableRenderer = new TableRenderer(COLUMNS); + export default class MrtOrgMemberList extends MrtCommand { static description = withDocs( t('commands.mrt.org.member.list.description', 'List members of a Managed Runtime organization'), @@ -61,6 +69,7 @@ export default class MrtOrgMemberList extends MrtCommand { @@ -84,7 +93,10 @@ export default class MrtOrgMemberList extends MrtCommand { // Confirmation prompt (skip if --force or --json) if (!force && !this.jsonEnabled()) { - const confirmed = await confirm({ - message: `⚠️ Reset will permanently delete all data and code in sandbox ${this.args.sandboxId}. Continue?`, - default: false, - }); + const confirmed = await confirm( + `⚠️ Reset will permanently delete all data and code in sandbox ${this.args.sandboxId}. Continue?`, + ); if (!confirmed) { this.log(t('commands.sandbox.reset.cancelled', 'Reset cancelled')); diff --git a/packages/b2c-cli/src/commands/setup/index.ts b/packages/b2c-cli/src/commands/setup/index.ts index b3ba3de7e..ef52f2f82 100644 --- a/packages/b2c-cli/src/commands/setup/index.ts +++ b/packages/b2c-cli/src/commands/setup/index.ts @@ -3,7 +3,7 @@ * 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 {confirm} from '@inquirer/prompts'; +import {confirm} from '@salesforce/b2c-tooling-sdk/ux'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {withDocs} from '../../i18n/index.js'; @@ -30,10 +30,7 @@ export default class SetupIndex extends BaseCommand { const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY); if (!hasInstance && isTTY) { - const shouldCreate = await confirm({ - message: 'No instance configured. Would you like to set one up?', - default: true, - }); + const shouldCreate = await confirm('No instance configured. Would you like to set one up?', {defaultYes: true}); if (shouldCreate) { await this.config.runCommand('setup:instance:create'); diff --git a/packages/b2c-cli/src/commands/setup/instance/remove.ts b/packages/b2c-cli/src/commands/setup/instance/remove.ts index d6dd9daef..8749c8764 100644 --- a/packages/b2c-cli/src/commands/setup/instance/remove.ts +++ b/packages/b2c-cli/src/commands/setup/instance/remove.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 {Args, Flags, ux} from '@oclif/core'; -import {confirm} from '@inquirer/prompts'; +import {confirm} from '@salesforce/b2c-tooling-sdk/ux'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {DwJsonSource} from '@salesforce/b2c-tooling-sdk/config'; import {withDocs} from '../../../i18n/index.js'; @@ -67,10 +67,7 @@ export default class SetupInstanceRemove extends BaseCommand, fields: DetailField[], labelWidth: number): void { for (const raw of fields) { const {label, value} = normalizeField(raw); - if (value === undefined) continue; + if (value === undefined || value === null) continue; ui.div({text: `${label}:`, width: labelWidth, padding: [0, 2, 0, 0]}, {text: String(value), padding: [0, 0, 0, 0]}); } } diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 3bce146eb..6bd989111 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -143,4 +143,4 @@ export type {ColumnDef, TableRenderOptions} from './table.js'; export {columnFlagsFor, selectColumns} from './columns.js'; export type {ColumnFlags, ColumnFlagsOptions, WarnFn} from './columns.js'; export {printFieldsBlock} from './details.js'; -export type {DetailField, DetailFieldObject, DetailSection, PrintFieldsBlockOptions} from './details.js'; +export type {DetailField, DetailFieldObject, DetailSection, DetailValue, PrintFieldsBlockOptions} from './details.js'; 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 bff4306e2..3de7da64f 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts @@ -52,6 +52,7 @@ * @module operations/bm-users */ export { + ACCESS_KEY_SCOPES, listBmUsers, getBmUser, whoamiBmUser, @@ -65,6 +66,7 @@ export { } from './users.js'; export type { + AccessKeyScope, BmUser, BmUsers, BmUserSearchResult, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts index 36f250ecd..073c802c2 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts @@ -37,6 +37,17 @@ export type BmUserSearchResult = components['schemas']['user_search_result']; */ export type BmAccessKeyDetails = components['schemas']['access_key_details']; +/** + * Valid access-key scopes accepted by the Data API + * `/users/{login}/access_key/{scope}` endpoints. + */ +export const ACCESS_KEY_SCOPES = ['WEBDAV_AND_STUDIO', 'AGENT_USER_AND_OCAPI', 'STOREFRONT'] as const; + +/** + * Access-key scope. One of {@link ACCESS_KEY_SCOPES}. + */ +export type AccessKeyScope = (typeof ACCESS_KEY_SCOPES)[number]; + /** * Updatable user fields for `patch` operations. * @@ -270,7 +281,7 @@ export async function searchBmUsers( * * @param instance - B2C instance * @param login - User login - * @param scope - Access key scope (e.g. 'WEBDAV', 'OCAPI', 'SCAPI') + * @param scope - Access key scope (one of {@link ACCESS_KEY_SCOPES}) * @returns Access key details */ export async function getBmUserAccessKey( From 3e7fbdc37d6a7d6897ad7ad0636699127d2804f6 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 7 May 2026 18:47:07 -0400 Subject: [PATCH 4/5] Add tests for bm whoami, bm users, and bm access-key commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 new test files following the bm/roles test patterns. Each covers: JSON-mode return shape, non-JSON output (where applicable), flag/arg behavior, and OCAPI error paths via the expectError helper. - whoami.test.ts (3 cases) - users/{list,get,delete}.test.ts (3 cases each) - users/search.test.ts (5 cases — covers convenience flags, raw --query passthrough, and invalid JSON rejection) - users/update.test.ts (4 cases — covers the field→snake_case mapping and "no fields" guard) - access-key/{get,delete}.test.ts cover both the explicit-login and whoami-fallback branches via two-call OCAPI stubs - access-key/{create,set}.test.ts cover scope flag and PATCH body shape CLI tests now: 1218 passing (was 1184). SDK tests unchanged at 1722. --- .../commands/bm/access-key/create.test.ts | 86 ++++++++++++++ .../commands/bm/access-key/delete.test.ts | 90 ++++++++++++++ .../test/commands/bm/access-key/get.test.ts | 110 ++++++++++++++++++ .../test/commands/bm/access-key/set.test.ts | 83 +++++++++++++ .../test/commands/bm/users/delete.test.ts | 70 +++++++++++ .../test/commands/bm/users/get.test.ts | 81 +++++++++++++ .../test/commands/bm/users/list.test.ts | 72 ++++++++++++ .../test/commands/bm/users/search.test.ts | 100 ++++++++++++++++ .../test/commands/bm/users/update.test.ts | 87 ++++++++++++++ .../b2c-cli/test/commands/bm/whoami.test.ts | 87 ++++++++++++++ 10 files changed, 866 insertions(+) create mode 100644 packages/b2c-cli/test/commands/bm/access-key/create.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/access-key/delete.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/access-key/get.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/access-key/set.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/users/delete.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/users/get.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/users/list.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/users/search.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/users/update.test.ts create mode 100644 packages/b2c-cli/test/commands/bm/whoami.test.ts diff --git a/packages/b2c-cli/test/commands/bm/access-key/create.test.ts b/packages/b2c-cli/test/commands/bm/access-key/create.test.ts new file mode 100644 index 000000000..4e3d7ed06 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/access-key/create.test.ts @@ -0,0 +1,86 @@ +/* + * 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 BmAccessKeyCreate from '../../../../src/commands/bm/access-key/create.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm access-key create', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmAccessKeyCreate, 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 an access key and returns secret in JSON mode', async () => { + const command: any = await createCommand({scope: 'STOREFRONT'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiPut = sinon.stub().resolves({ + data: {access_key: 'secret-value', enabled: true, expiration_date: '2027-01-01'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + + const result = await command.run(); + expect(result.access_key).to.equal('secret-value'); + expect(result.enabled).to.equal(true); + expect(ocapiPut.calledOnce).to.equal(true); + expect(ocapiPut.firstCall.args[0]).to.equal('/users/{login}/access_key/{scope}'); + expect(ocapiPut.firstCall.args[1].params.path).to.deep.equal({ + login: 'user@x.com', + scope: 'STOREFRONT', + }); + }); + + it('displays access key details in non-JSON mode', async () => { + const command: any = await createCommand({scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiPut = sinon.stub().resolves({ + data: {access_key: 'secret-value', enabled: true, expiration_date: '2027-01-01'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); + + const result = await command.run(); + expect(result.access_key).to.equal('secret-value'); + expect(stdoutStub.calledOnce).to.equal(true); + }); + + it('throws when API returns a fault', async () => { + const command: any = await createCommand({scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const ocapiPut = sinon.stub().resolves({ + data: undefined, + error: {fault: {message: 'Bad request'}}, + response: {status: 400, statusText: 'Bad Request'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + + await expectError(() => command.run(), /Failed to create access key/); + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/access-key/delete.test.ts b/packages/b2c-cli/test/commands/bm/access-key/delete.test.ts new file mode 100644 index 000000000..2fb9c8b0c --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/access-key/delete.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmAccessKeyDelete from '../../../../src/commands/bm/access-key/delete.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm access-key delete', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmAccessKeyDelete, 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 an access key with --force in JSON mode', async () => { + const command: any = await createCommand({force: true, scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + + const result = await command.run(); + expect(result).to.deep.equal({ + success: true, + login: 'user@x.com', + scope: 'WEBDAV_AND_STUDIO', + hostname: 'example.com', + }); + expect(ocapiDelete.calledOnce).to.equal(true); + expect(ocapiDelete.firstCall.args[0]).to.equal('/users/{login}/access_key/{scope}'); + expect(ocapiDelete.firstCall.args[1].params.path).to.deep.equal({ + login: 'user@x.com', + scope: 'WEBDAV_AND_STUDIO', + }); + }); + + it('falls back to whoami when login arg is omitted', async () => { + const command: any = await createCommand({force: true, scope: 'WEBDAV_AND_STUDIO'}, {}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().resolves({data: {login: 'me@x.com'}, error: undefined}); + const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet, DELETE: ocapiDelete}})); + + const result = await command.run(); + expect(result.success).to.equal(true); + expect(result.login).to.equal('me@x.com'); + expect(ocapiGet.calledOnce).to.equal(true); + expect(ocapiGet.firstCall.args[0]).to.equal('/users/this'); + expect(ocapiDelete.calledOnce).to.equal(true); + expect(ocapiDelete.firstCall.args[1].params.path).to.deep.equal({ + login: 'me@x.com', + scope: 'WEBDAV_AND_STUDIO', + }); + }); + + it('throws on 404', async () => { + const command: any = await createCommand({force: true, scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + + const ocapiDelete = sinon.stub().resolves({ + data: undefined, + error: {fault: {message: 'Access key not found'}}, + response: {status: 404, statusText: 'Not Found'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + + await expectError(() => command.run(), /Failed to delete access key/); + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/access-key/get.test.ts b/packages/b2c-cli/test/commands/bm/access-key/get.test.ts new file mode 100644 index 000000000..a18203b8d --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/access-key/get.test.ts @@ -0,0 +1,110 @@ +/* + * 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 BmAccessKeyGet from '../../../../src/commands/bm/access-key/get.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm access-key get', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmAccessKeyGet, 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 access key details in JSON mode with explicit login', async () => { + const command: any = await createCommand({scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().resolves({ + data: {enabled: true, expiration_date: '2027-01-01'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const result = await command.run(); + expect(result.enabled).to.equal(true); + expect(result.expiration_date).to.equal('2027-01-01'); + expect(ocapiGet.calledOnce).to.equal(true); + expect(ocapiGet.firstCall.args[0]).to.equal('/users/{login}/access_key/{scope}'); + expect(ocapiGet.firstCall.args[1].params.path).to.deep.equal({ + login: 'user@x.com', + scope: 'WEBDAV_AND_STUDIO', + }); + }); + + it('falls back to whoami when login arg is omitted', async () => { + const command: any = await createCommand({scope: 'WEBDAV_AND_STUDIO'}, {}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().callsFake((path: string) => { + if (path === '/users/this') { + return Promise.resolve({data: {login: 'me@x.com'}, error: undefined}); + } + return Promise.resolve({data: {enabled: true}, error: undefined}); + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const result = await command.run(); + expect(result.enabled).to.equal(true); + expect(ocapiGet.callCount).to.equal(2); + expect(ocapiGet.firstCall.args[0]).to.equal('/users/this'); + expect(ocapiGet.secondCall.args[0]).to.equal('/users/{login}/access_key/{scope}'); + expect(ocapiGet.secondCall.args[1].params.path).to.deep.equal({ + login: 'me@x.com', + scope: 'WEBDAV_AND_STUDIO', + }); + }); + + it('displays access key fields in non-JSON mode', async () => { + const command: any = await createCommand({scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().resolves({ + data: {enabled: true, expiration_date: '2027-01-01'}, + 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.enabled).to.equal(true); + expect(stdoutStub.calledOnce).to.equal(true); + }); + + it('throws on 404', async () => { + const command: any = await createCommand({scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().resolves({ + data: undefined, + error: {fault: {message: 'Access key not found'}}, + response: {status: 404, statusText: 'Not Found'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + await expectError(() => command.run(), /Failed to get access key/); + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/access-key/set.test.ts b/packages/b2c-cli/test/commands/bm/access-key/set.test.ts new file mode 100644 index 000000000..b72cf7c04 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/access-key/set.test.ts @@ -0,0 +1,83 @@ +/* + * 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 BmAccessKeySet from '../../../../src/commands/bm/access-key/set.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm access-key set', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmAccessKeySet, 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('enables an access key with --enabled', async () => { + const command: any = await createCommand({enabled: true, scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiPatch = sinon.stub().resolves({ + data: {enabled: true, expiration_date: '2027-01-01'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + + const result = await command.run(); + expect(result.enabled).to.equal(true); + expect(ocapiPatch.calledOnce).to.equal(true); + expect(ocapiPatch.firstCall.args[0]).to.equal('/users/{login}/access_key/{scope}'); + expect(ocapiPatch.firstCall.args[1].params.path).to.deep.equal({ + login: 'user@x.com', + scope: 'WEBDAV_AND_STUDIO', + }); + expect(ocapiPatch.firstCall.args[1].body).to.deep.equal({enabled: true}); + }); + + it('disables an access key with --no-enabled', async () => { + const command: any = await createCommand({enabled: false, scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiPatch = sinon.stub().resolves({ + data: {enabled: false, expiration_date: '2027-01-01'}, + error: undefined, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + + const result = await command.run(); + expect(result.enabled).to.equal(false); + expect(ocapiPatch.firstCall.args[1].body).to.deep.equal({enabled: false}); + }); + + it('throws on 404', async () => { + const command: any = await createCommand({enabled: true, scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const ocapiPatch = sinon.stub().resolves({ + data: undefined, + error: {fault: {message: 'Access key not found'}}, + response: {status: 404, statusText: 'Not Found'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + + await expectError(() => command.run(), /Failed to update access key/); + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/users/delete.test.ts b/packages/b2c-cli/test/commands/bm/users/delete.test.ts new file mode 100644 index 000000000..3d684dded --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/users/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 BmUsersDelete from '../../../../src/commands/bm/users/delete.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm users delete', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmUsersDelete, 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 user with --force in JSON mode', async () => { + const command: any = await createCommand({force: true}, {login: 'user@x.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.login).to.equal('user@x.com'); + expect(result.hostname).to.equal('example.com'); + expect(ocapiDelete.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 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}})); + + 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 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(ocapiDelete.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 new file mode 100644 index 000000000..2124dc431 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/users/get.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {ux} from '@oclif/core'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmUsersGet from '../../../../src/commands/bm/users/get.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm users get', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmUsersGet, 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 user details in JSON mode', async () => { + const command: any = await createCommand({}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: true}); + + const mockUser = { + login: 'user@x.com', + email: 'user@x.com', + first_name: 'Test', + last_name: '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); + }); + + it('displays user details in non-JSON mode', async () => { + const command: any = await createCommand({}, {login: 'user@x.com'}); + 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}})); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); + + const result = await command.run(); + expect(result.login).to.equal('user@x.com'); + expect(stdoutStub.calledOnce).to.equal(true); + }); + + 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 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}})); + + 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 new file mode 100644 index 000000000..5a6409cef --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/users/list.test.ts @@ -0,0 +1,72 @@ +/* + * 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 BmUsersList from '../../../../src/commands/bm/users/list.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm users list', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}) { + return createTestCommand(BmUsersList, 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 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}})); + + 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'); + }); + + it('prints "no users" message when empty in non-JSON mode', async () => { + const command: any = await createCommand(); + 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}})); + + const result = await command.run(); + expect(result.count).to.equal(0); + expect(logStub.calledWith(sinon.match(/No users found/))).to.equal(true); + }); + + 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: 'forbidden'}}, + response: {status: 403, statusText: 'Forbidden'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + await expectError(() => command.run(), 'Failed to list users'); + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/users/search.test.ts b/packages/b2c-cli/test/commands/bm/users/search.test.ts new file mode 100644 index 000000000..65d10f118 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/users/search.test.ts @@ -0,0 +1,100 @@ +/* + * 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 BmUsersSearch from '../../../../src/commands/bm/users/search.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm users search', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}) { + return createTestCommand(BmUsersSearch, 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 search results in JSON mode with no flags (match_all)', async () => { + const command: any = await createCommand(); + stubCommon(command, {jsonEnabled: true}); + + const mockResult = {hits: [{login: 'a@x.com'}], total: 1, count: 1}; + const ocapiPost = sinon.stub().resolves({data: mockResult, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: ocapiPost}})); + + const result = await command.run(); + expect(result.total).to.equal(1); + expect(ocapiPost.calledOnce).to.equal(true); + expect(ocapiPost.firstCall.args[0]).to.equal('/user_search'); + const body = ocapiPost.firstCall.args[1].body; + expect(body.query).to.deep.equal({match_all_query: {}}); + }); + + it('builds text_query from --search-phrase', async () => { + const command: any = await createCommand({'search-phrase': 'smith'}); + stubCommon(command, {jsonEnabled: true}); + + const ocapiPost = sinon.stub().resolves({data: {hits: [], total: 0}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: ocapiPost}})); + + await command.run(); + const body = ocapiPost.firstCall.args[1].body; + expect(body.query).to.deep.equal({ + text_query: { + fields: ['login', 'email', 'first_name', 'last_name'], + search_phrase: 'smith', + }, + }); + }); + + it('passes raw --query JSON verbatim', async () => { + const raw = {text_query: {fields: ['login'], search_phrase: 'foo'}}; + const command: any = await createCommand({query: JSON.stringify(raw)}); + stubCommon(command, {jsonEnabled: true}); + + const ocapiPost = sinon.stub().resolves({data: {hits: [], total: 0}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: ocapiPost}})); + + await command.run(); + const body = ocapiPost.firstCall.args[1].body; + expect(body.query).to.deep.equal(raw); + }); + + it('errors on invalid --query JSON', async () => { + const command: any = await createCommand({query: 'not-valid-json'}); + stubCommon(command, {jsonEnabled: true}); + + // No POST should be invoked, but stub instance defensively. + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: sinon.stub()}})); + + await expectError(() => command.run(), /Invalid --query JSON/); + }); + + 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 ocapiPost = sinon.stub().resolves({ + data: undefined, + error: {fault: {message: 'bad query'}}, + response: {status: 400, statusText: 'Bad Request'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {POST: ocapiPost}})); + + await expectError(() => command.run(), 'Failed to search 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 new file mode 100644 index 000000000..0d4346af5 --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/users/update.test.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 + */ + +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmUsersUpdate from '../../../../src/commands/bm/users/update.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm users update', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmUsersUpdate, 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('updates user with --disabled in JSON mode', async () => { + const command: any = await createCommand({disabled: true}, {login: 'user@x.com'}); + 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}})); + + 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}); + }); + + it('combines multiple field flags into PATCH body', 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 ocapiPatch = sinon.stub().resolves({data: {login: 'user@x.com'}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + + 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', + }); + }); + + it('errors when no fields are specified', async () => { + 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 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}})); + + await expectError(() => command.run(), 'Failed to update user'); + }); +}); diff --git a/packages/b2c-cli/test/commands/bm/whoami.test.ts b/packages/b2c-cli/test/commands/bm/whoami.test.ts new file mode 100644 index 000000000..bd5c4625d --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/whoami.test.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 + */ + +import {ux} from '@oclif/core'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmWhoami from '../../../src/commands/bm/whoami.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../helpers/test-setup.js'; + +describe('bm whoami', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}) { + return createTestCommand(BmWhoami, 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 user details in JSON mode', async () => { + const command: any = await createCommand(); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + + const mockUser = { + login: 'admin@example.com', + email: 'admin@example.com', + first_name: 'Admin', + last_name: 'User', + }; + const ocapiGet = sinon.stub().resolves({data: mockUser, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const result = await command.run(); + expect(result).to.deep.equal(mockUser); + expect(ocapiGet.calledOnce).to.equal(true); + expect(ocapiGet.firstCall.args[0]).to.equal('/users/this'); + }); + + it('displays user details in non-JSON mode', async () => { + const command: any = await createCommand(); + stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); + + const mockUser = { + login: 'admin@example.com', + email: 'admin@example.com', + first_name: 'Admin', + last_name: 'User', + }; + const ocapiGet = sinon.stub().resolves({data: mockUser, 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).to.deep.equal(mockUser); + expect(stdoutStub.calledOnce).to.equal(true); + }); + + it('throws when OCAPI returns UserNotAvailableException', async () => { + const command: any = await createCommand(); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().resolves({ + data: undefined, + error: {fault: {message: 'No user was provided with the OAuth token.'}}, + response: {status: 401, statusText: 'Unauthorized'}, + }); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + await expectError(() => command.run(), /Failed to get current user/); + }); +}); From 96fd9d82bf2c07d3acf49853a86f62fd68ea5c66 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 7 May 2026 19:10:18 -0400 Subject: [PATCH 5/5] Strengthen authentication coverage in bm CLI docs bm.md: - Lead the Authentication section with the two flows (client-credentials vs user-auth) and explicit setup before the "defaults" table. - Document --user-auth, --auth-methods, and SFCC_AUTH_METHODS overrides with concrete examples. - Annotate the OCAPI permissions table with which command uses each resource so readers know what to grant. - Add a dedicated subsection on the Manage_Users_Access_Keys BM functional permission required for access-key writes. - Add Configuration Examples block. authentication.md: - Add "BM administration" entry under "Minimal Configuration by Feature" with the importable JSON snippet covering /roles, /users, /users/this, /users/*/access_key/*, and /user_search. - Add a tip box explaining the user-identity requirement on whoami / access-key endpoints and cross-link back to /cli/bm#authentication. Both pages now properly cross-link to each other. --- docs/cli/bm.md | 67 +++++++++++++++++++++++++++--------- docs/guide/authentication.md | 49 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 17 deletions(-) diff --git a/docs/cli/bm.md b/docs/cli/bm.md index ca40ad230..1e496608b 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -8,33 +8,66 @@ Commands for administering instance-level Business Manager resources via the OCA ## Authentication -Most BM commands accept either client credentials or browser-based user authentication. A handful require a *real BM user identity* — the CLI defaults those to user-auth automatically. +BM commands authenticate via OAuth against the configured Commerce Cloud instance. Two flows are supported: + +- **Client credentials** — for automation and CI/CD. Configure an Account Manager API client and grant it the OCAPI permissions listed below. Pass credentials via `--client-id` / `--client-secret`, the `SFCC_CLIENT_ID` / `SFCC_CLIENT_SECRET` environment variables, or `dw.json`. +- **User auth (browser)** — for interactive use. Pass `--user-auth` (or run `b2c auth login` once and reuse the saved session). The CLI opens a browser and the resulting token carries your BM user identity. + +A handful of endpoints require *a real BM user identity* and cannot use service-client tokens — the CLI defaults those to user-auth automatically: | Command group | Default auth | Why | |---|---|---| | `b2c bm roles ...` | client-credentials → jwt → implicit | OCAPI permissions for `/roles` | | `b2c bm users {list,get,search,update,delete}` | client-credentials → jwt → implicit | OCAPI permissions for `/users` | -| `b2c bm whoami` | **implicit (browser)** | OCAPI `/users/this` requires the token to resolve to a BM user | -| `b2c bm access-key ...` | **implicit (browser)** | OCAPI access-key endpoints require "a valid user" plus the `Manage_Users_Access_Keys` functional permission | +| `b2c bm whoami` | **implicit (browser)** | `/users/this` requires the token to resolve to a BM user | +| `b2c bm access-key ...` | **implicit (browser)** | Access-key endpoints require *a valid user* plus the `Manage_Users_Access_Keys` BM functional permission | -Override the default with `--auth-methods client-credentials` (or `--client-secret` flags) when your service-client setup is configured to issue user-bearing tokens. +Override the auto-defaulted user-auth with `--auth-methods client-credentials` (or `--client-secret`) when your service-client setup is configured to issue user-bearing tokens. The interactive defaults can also be skipped end-to-end by exporting `SFCC_AUTH_METHODS=client-credentials,jwt` in CI. -For complete setup instructions see the [Authentication Guide](/guide/authentication). +See the [Authentication Guide](/guide/authentication) for end-to-end setup, including the [BM administration OCAPI snippet](/guide/authentication#minimal-configuration-by-feature). ### Required OCAPI Permissions -| Resource | Methods | -|----------|---------| -| `/roles` | GET | -| `/roles/*` | GET, PUT, DELETE | -| `/roles/*/users` | GET, PUT, DELETE | -| `/users` | GET | -| `/users/*` | GET, PATCH, DELETE | -| `/users/this` | GET | -| `/users/*/access_key/*` | GET, PUT, PATCH, DELETE | -| `/user_search` | POST | - -Access-key writes additionally require the `Manage_Users_Access_Keys` functional permission on the BM user account. +Add these resources to the Data API client configuration in Business Manager (**Administration** > **Site Development** > **Open Commerce API Settings** > **Data API**): + +| Resource | Methods | Used by | +|----------|---------|---------| +| `/roles` | GET | `bm roles list` | +| `/roles/*` | GET, PUT, DELETE | `bm roles get/create/delete` | +| `/roles/*/users` | GET | `bm roles get --expand users` | +| `/roles/*/users/*` | PUT, DELETE | `bm roles grant/revoke` | +| `/roles/*/permissions` | GET, PUT | `bm roles permissions get/set` | +| `/users` | GET | `bm users list` | +| `/users/*` | GET, PATCH, DELETE | `bm users get/update/delete` | +| `/users/this` | GET | `bm whoami`, `bm access-key` (optional login fallback) | +| `/users/*/access_key/*` | GET, PUT, PATCH, DELETE | `bm access-key get/create/set/delete` | +| `/user_search` | POST | `bm users search` | + +For an importable JSON snippet covering all BM administration endpoints, see [Minimal Configuration by Feature](/guide/authentication#minimal-configuration-by-feature) in the Authentication Guide. + +### Required BM Functional Permission + +Access-key writes (`bm access-key {create,set,delete}`) additionally require the **Manage_Users_Access_Keys** BM functional permission on the user account performing the request. Grant it via Business Manager: **Administration** > **Roles & Permissions**. This is why the CLI defaults `bm access-key` commands to user-auth — service clients cannot carry BM functional permissions. + +### Configuration Examples + +```bash +# Interactive (browser login on first command, session reused after): +b2c auth login --instance my-sandbox +b2c bm whoami + +# Client credentials (where supported): +export SFCC_CLIENT_ID=your-client-id +export SFCC_CLIENT_SECRET=your-client-secret +export SFCC_SERVER=my-sandbox.demandware.net +b2c bm users list + +# Force user-auth on a command that defaults to client-credentials: +b2c bm users list --user-auth + +# Force client-credentials on a command that defaults to user-auth (advanced): +b2c bm access-key get --auth-methods client-credentials +``` --- diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 6d283932c..8428ff5bb 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -423,6 +423,55 @@ For operations that interact with B2C Commerce instances (code deployment, jobs, } ``` +**BM administration (`bm roles`, `bm users`, `bm whoami`, `bm access-key`):** + +```json +{ + "resource_id": "/roles", + "methods": ["get"] +}, +{ + "resource_id": "/roles/*", + "methods": ["get", "put", "delete"] +}, +{ + "resource_id": "/roles/*/users", + "methods": ["get"] +}, +{ + "resource_id": "/roles/*/users/*", + "methods": ["put", "delete"] +}, +{ + "resource_id": "/roles/*/permissions", + "methods": ["get", "put"] +}, +{ + "resource_id": "/users", + "methods": ["get"] +}, +{ + "resource_id": "/users/*", + "methods": ["get", "patch", "delete"] +}, +{ + "resource_id": "/users/this", + "methods": ["get"] +}, +{ + "resource_id": "/users/*/access_key/*", + "methods": ["get", "put", "patch", "delete"] +}, +{ + "resource_id": "/user_search", + "methods": ["post"] +} +``` + +::: tip BM functional permissions +`bm whoami` and the `bm access-key` family additionally require *a real BM user identity*. Service-client tokens cannot resolve to a BM user, so the CLI defaults these commands to browser-based user auth. Access-key writes also require the **Manage_Users_Access_Keys** BM functional permission on the user account performing the request — grant it via **Administration** > **Roles & Permissions** in Business Manager. See [BM Commands → Authentication](/cli/bm#authentication) for details. +::: + ## SCAPI Authentication SCAPI commands (eCDN, SCAPI schemas, custom APIs) require OAuth authentication with specific roles and scopes.