From 7ee5ab6b3638511f676535b26182bfa8a0649c82 Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Tue, 31 Mar 2026 19:50:45 +0530 Subject: [PATCH 1/3] @W-21766828 add storage,multi realm usage and settings support --- docs/cli/sandbox.md | 150 +++++++++++++++++- .../commands/sandbox/realm/configuration.ts | 113 +++++++++++++ .../src/commands/sandbox/realm/list.ts | 50 ++---- .../src/commands/sandbox/realm/usages.ts | 134 ++++++++++++++++ .../b2c-cli/src/commands/sandbox/settings.ts | 99 ++++++++++++ .../b2c-cli/src/commands/sandbox/storage.ts | 88 ++++++++++ .../sandbox/realm/configuration.test.ts | 80 ++++++++++ .../commands/sandbox/realm/usages.test.ts | 92 +++++++++++ .../test/commands/sandbox/settings.test.ts | 99 ++++++++++++ .../test/commands/sandbox/storage.test.ts | 96 +++++++++++ 10 files changed, 958 insertions(+), 43 deletions(-) create mode 100644 packages/b2c-cli/src/commands/sandbox/realm/configuration.ts create mode 100644 packages/b2c-cli/src/commands/sandbox/realm/usages.ts create mode 100644 packages/b2c-cli/src/commands/sandbox/settings.ts create mode 100644 packages/b2c-cli/src/commands/sandbox/storage.ts create mode 100644 packages/b2c-cli/test/commands/sandbox/realm/configuration.test.ts create mode 100644 packages/b2c-cli/test/commands/sandbox/realm/usages.test.ts create mode 100644 packages/b2c-cli/test/commands/sandbox/settings.test.ts create mode 100644 packages/b2c-cli/test/commands/sandbox/storage.test.ts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 68b81569..6f0258b6 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -604,6 +604,79 @@ If detailed usage data is present (granular history, profiles, etc.), the comman --- +## b2c sandbox settings + +Show effective OCAPI and WebDAV settings for a specific sandbox. + +### Usage + +```bash +b2c sandbox settings +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +### Examples + +```bash +# Show settings summary for a sandbox +b2c sandbox settings zzzz-001 + +# Output full settings payload as JSON +b2c sandbox settings zzzz-001 --json +``` + +### Output + +When not using `--json`, the command prints: + +- Number of OCAPI client entries +- Number of WebDAV client entries +- A short per-client breakdown for each settings type + +--- + +## b2c sandbox storage + +Show filesystem storage usage for a specific sandbox. + +### Usage + +```bash +b2c sandbox storage +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +### Examples + +```bash +# Show storage table for a sandbox +b2c sandbox storage zzzz-001 + +# Output raw storage response as JSON +b2c sandbox storage zzzz-001 --json +``` + +### Output + +When not using `--json`, the command prints a table with one row per filesystem: + +- Filesystem name +- Total space (MB) +- Used space (MB) +- Used percentage + +--- + ## Sandbox Aliases Sandbox aliases let you access a sandbox via a custom hostname instead of the default instance hostname. @@ -968,9 +1041,11 @@ For the complete response including all metadata, use the `--json` flag. Realm commands operate at the **realm** level rather than on an individual sandbox. They are available as both `realm` topic commands and as `sandbox realm` subcommands: - `b2c realm list` (`b2c sandbox realm list`) +- `b2c realm configuration` (`b2c sandbox realm configuration`) - `b2c realm get` (`b2c sandbox realm get`) - `b2c realm update` (`b2c sandbox realm update`) - `b2c realm usage` (`b2c sandbox realm usage`) +- `b2c realm usages` (`b2c sandbox realm usages`) ### Required Access for Realm Commands @@ -990,7 +1065,7 @@ b2c realm list [REALM] | Argument | Description | Required | |----------|-------------|----------| -| `REALM` | Specific realm ID (four-letter ID) to get details for | No | +| `REALM` | Optional realm ID filter (four-letter ID) | No | #### Examples @@ -1005,7 +1080,35 @@ b2c realm list zzzz b2c realm list --json ``` -When `REALM` is omitted, the command discovers realms from the `/me` endpoint and then fetches configuration for each. +When `REALM` is omitted, the command discovers realms from the `/me` endpoint. + +### b2c realm configuration + +Get sandbox configuration for a specific realm. + +#### Usage + +```bash +b2c realm configuration +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `REALM` | Realm ID (four-letter ID) | Yes | + +#### Examples + +```bash +# Get realm sandbox configuration +b2c realm configuration zzzz + +# JSON output +b2c realm configuration zzzz --json +``` + +When not using `--json`, the command prints configuration details such as emails, sandbox limits, TTL values, and start/stop schedulers. ### b2c realm get @@ -1143,3 +1246,46 @@ When not using `--json`, the command prints a summary including: If detailed usage is available, it prints a hint to re-run with `--json` for the full structure. If no usage data is returned for the requested period, it prints a friendly message instead of failing. +### b2c realm usages + +Show usage information for multiple realms in one request. + +#### Usage + +```bash +b2c realm usages [FLAGS] +``` + +#### Flags + +| Flag | Description | +|------|-------------| +| `--realm` | Realm IDs to include (repeat flag or provide comma-separated values) | +| `--from` | Earliest date to include in usage (ISO 8601) | +| `--to` | Latest date to include in usage (ISO 8601) | +| `--detailed-report` | Include detailed usage information in the response | + +If `--realm` is omitted, the command auto-discovers realms from `/me` and queries usage for all discovered realms. + +#### Examples + +```bash +# Usage for all realms available to the current user +b2c realm usages + +# Usage for two specific realms +b2c realm usages --realm zzzz --realm yyyy + +# Usage for comma-separated realms and date range +b2c realm usages --realm zzzz,yyyy --from 2024-01-01 --to 2024-01-31 + +# Detailed report in JSON +b2c realm usages --detailed-report --json +``` + +When not using `--json`, the command prints one row per realm with summary metrics such as: + +- Active / created / deleted sandbox counts +- Minutes up / minutes down +- Sandbox seconds + diff --git a/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts new file mode 100644 index 00000000..57c23041 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts @@ -0,0 +1,113 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +type RealmConfigurationModel = OdsComponents['schemas']['RealmConfigurationModel']; +type RealmConfigurationResponse = OdsComponents['schemas']['RealmConfigurationResponse']; + +/** + * Get realm sandbox configuration. + */ +export default class SandboxRealmConfiguration extends OdsCommand { + static aliases = ['ods:realm:configuration', 'realm:configuration']; + + static args = { + realm: Args.string({ + description: 'Realm ID (four-letter ID)', + required: true, + }), + }; + + static description = withDocs( + t('commands.realm.configuration.description', 'Get sandbox configuration for a realm'), + '/cli/realm.html#b2c-realm-configuration', + ); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %> zzzz', '<%= config.bin %> <%= command.id %> zzzz --json']; + + async run(): Promise { + const {args} = await this.parse(SandboxRealmConfiguration); + const realm = args.realm; + + this.log(t('commands.realm.configuration.fetching', 'Fetching configuration for realm {{realm}}...', {realm})); + + const result = await this.odsClient.GET('/realms/{realm}/configuration', { + params: {path: {realm}}, + }); + + if (result.error) { + this.error( + t('commands.realm.configuration.error', 'Failed to fetch configuration for realm {{realm}}: {{message}}', { + realm, + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as RealmConfigurationResponse | undefined)?.data; + if (!data) { + this.log(t('commands.realm.configuration.noData', 'No configuration data was returned for this realm.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as RealmConfigurationResponse; + } + + this.printConfiguration(data); + return data; + } + + private printConfiguration(configuration: RealmConfigurationModel): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cfg = configuration as any; + + console.log('Realm Configuration'); + console.log('───────────────────'); + + const maxTtlRaw = cfg.sandbox?.sandboxTTL?.maximum as number | undefined; + const maxTtlDisplay = maxTtlRaw === undefined ? undefined : maxTtlRaw >= 2_147_483_647 ? '0' : String(maxTtlRaw); + + const rows: Array<[string, string | undefined]> = [ + ['Enabled', cfg.enabled === undefined ? undefined : String(cfg.enabled)], + ['Emails', Array.isArray(cfg.emails) ? cfg.emails.join(', ') : undefined], + ['Limits Enabled', cfg.sandbox?.limitsEnabled === undefined ? undefined : String(cfg.sandbox.limitsEnabled)], + [ + 'Total Sandboxes', + cfg.sandbox?.totalNumberOfSandboxes === undefined ? undefined : String(cfg.sandbox.totalNumberOfSandboxes), + ], + ['Max Sandbox TTL', maxTtlDisplay], + [ + 'Default Sandbox TTL', + cfg.sandbox?.sandboxTTL?.defaultValue === undefined ? undefined : String(cfg.sandbox.sandboxTTL.defaultValue), + ], + [ + 'Local Users Allowed', + cfg.sandbox?.localUsersAllowed === undefined ? undefined : String(cfg.sandbox.localUsersAllowed), + ], + ]; + + for (const [label, value] of rows) { + if (value !== undefined) { + console.log(`${label}: ${value}`); + } + } + + if (cfg.sandbox?.startScheduler) { + console.log(`Start Scheduler: ${JSON.stringify(cfg.sandbox.startScheduler)}`); + } + + if (cfg.sandbox?.stopScheduler) { + console.log(`Stop Scheduler: ${JSON.stringify(cfg.sandbox.stopScheduler)}`); + } + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/realm/list.ts b/packages/b2c-cli/src/commands/sandbox/realm/list.ts index 5d9209ed..fbd91d4a 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/list.ts @@ -6,29 +6,26 @@ import {Args} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../../i18n/index.js'; -type RealmConfigurationModel = OdsComponents['schemas']['RealmConfigurationModel']; - -interface RealmWithUsage { +interface RealmItem { realmId: string; - configuration?: RealmConfigurationModel; } interface RealmListResponse { - realms: RealmWithUsage[]; + realms: RealmItem[]; } /** - * List realms eligible for sandbox management, optionally including usage. + * List realms eligible for sandbox management. */ export default class SandboxRealmList extends OdsCommand { static aliases = ['ods:realm:list', 'realm:list']; static args = { realm: Args.string({ - description: 'Specific realm ID (four-letter ID) to get details for', + description: 'Optional realm ID filter (four-letter ID)', required: false, }), }; @@ -70,32 +67,7 @@ export default class SandboxRealmList extends OdsCommand ({realmId})); const response: RealmListResponse = {realms}; @@ -110,16 +82,12 @@ export default class SandboxRealmList extends OdsCommand { + static aliases = ['ods:realm:usages', 'realm:usages']; + + static description = withDocs( + t('commands.realm.usages.description', 'Show usage information for multiple realms'), + '/cli/realm.html#b2c-realm-usages', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --realm zzzz --realm yyyy', + '<%= config.bin %> <%= command.id %> --realm zzzz,yyyy --from 2026-02-08 --to 2026-02-11', + '<%= config.bin %> <%= command.id %> --detailed-report --json', + ]; + + static flags = { + realm: Flags.string({ + description: 'Realm ID(s). Repeat flag or provide comma-separated values', + multiple: true, + multipleNonGreedy: true, + delimiter: ',', + }), + from: Flags.string({ + description: 'Earliest date to include in usage (ISO 8601)', + }), + to: Flags.string({ + description: 'Latest date to include in usage (ISO 8601)', + }), + 'detailed-report': Flags.boolean({ + description: 'Include detailed usage information in the response', + default: false, + }), + } as const; + + async run(): Promise { + const {flags} = await this.parse(SandboxRealmUsages); + + const realms = await this.resolveRealms(flags.realm); + if (realms.length === 0) { + this.log(t('commands.realm.usages.noRealms', 'No realms found for the current user.')); + return undefined; + } + + const result = await this.odsClient.POST('/realms/usages', { + body: { + from: flags.from, + to: flags.to, + realms, + detailedReport: flags['detailed-report'], + }, + }); + + if (result.error) { + this.error( + t('commands.realm.usages.error', 'Failed to fetch multi-realm usage: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as MultiRealmUsageResponse | undefined)?.data; + if (!data || data.length === 0) { + this.log(t('commands.realm.usages.noData', 'No usage data was returned for the requested realms.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as MultiRealmUsageResponse; + } + + this.printUsage(data); + return data; + } + + private printUsage(items: MultiRealmUsageModel[]): void { + console.log('Realm Active Created Deleted Minutes Up Minutes Down Sandbox Seconds'); + console.log('───── ────── ─────── ─────── ────────── ──────────── ───────────────'); + + for (const item of items) { + const usage = item.realmUsage; + const row = [ + String(item.realmName ?? '-').padEnd(5), + String(usage?.activeSandboxes ?? '-').padStart(6), + String(usage?.createdSandboxes ?? '-').padStart(7), + String(usage?.deletedSandboxes ?? '-').padStart(7), + String(usage?.minutesUp ?? '-').padStart(10), + String(usage?.minutesDown ?? '-').padStart(12), + String(usage?.sandboxSeconds ?? '-').padStart(15), + ]; + + console.log(row.join(' ')); + + if (item.error) { + console.log(` ! ${item.error}`); + } + } + } + + private async resolveRealms(inputRealms: string[] | undefined): Promise { + if (inputRealms && inputRealms.length > 0) { + return inputRealms; + } + + const meResult = await this.odsClient.GET('/me', {}); + if (meResult.error || !meResult.data?.data?.realms) { + this.error( + t('commands.realm.usages.realmsError', 'Failed to resolve realms from current user: {{message}}', { + message: getApiErrorMessage(meResult.error, meResult.response), + }), + ); + } + + return meResult.data.data.realms ?? []; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/settings.ts b/packages/b2c-cli/src/commands/sandbox/settings.ts new file mode 100644 index 00000000..133e059c --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/settings.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} from '@oclif/core'; +import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../i18n/index.js'; + +type SandboxSettings = OdsComponents['schemas']['SandboxSettings']; +type SandboxSettingsResponse = OdsComponents['schemas']['SandboxSettingsResponse']; + +/** + * Show sandbox settings. + */ +export default class SandboxSettingsCommand extends OdsCommand { + static aliases = ['ods:settings']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., zzzz-001)', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.settings.description', 'Show settings for a specific sandbox'), + '/cli/sandbox.html#b2c-sandbox-settings', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz-001', + '<%= config.bin %> <%= command.id %> zzzz-001 --json', + ]; + + async run(): Promise { + const {args} = await this.parse(SandboxSettingsCommand); + const sandboxId = await this.resolveSandboxId(args.sandboxId); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/settings', { + params: { + path: {sandboxId}, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.settings.error', 'Failed to fetch sandbox settings: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as SandboxSettingsResponse | undefined)?.data; + if (!data) { + this.log(t('commands.sandbox.settings.noData', 'No settings were returned for this sandbox.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as SandboxSettingsResponse; + } + + this.printSettings(data); + return data; + } + + private printSettings(settings: SandboxSettings): void { + const ocapi = settings.ocapi ?? []; + const webdav = settings.webdav ?? []; + + console.log('Sandbox Settings'); + console.log('────────────────'); + console.log(`OCAPI client entries: ${ocapi.length}`); + console.log(`WebDAV client entries: ${webdav.length}`); + + if (ocapi.length > 0) { + console.log(); + console.log('OCAPI'); + for (const entry of ocapi) { + const resources = entry.resources?.length ?? 0; + console.log(` - ${entry.client_id ?? 'unknown-client'} (${resources} resource rules)`); + } + } + + if (webdav.length > 0) { + console.log(); + console.log('WebDAV'); + for (const entry of webdav) { + const permissions = entry.permissions?.length ?? 0; + console.log(` - ${entry.client_id ?? 'unknown-client'} (${permissions} permission rules)`); + } + } + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/storage.ts b/packages/b2c-cli/src/commands/sandbox/storage.ts new file mode 100644 index 00000000..17fa077f --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/storage.ts @@ -0,0 +1,88 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../i18n/index.js'; + +type SandboxStorageModel = OdsComponents['schemas']['SandboxStorageModel']; +type SandboxStorageResponse = OdsComponents['schemas']['SandboxStorageResponse']; + +/** + * Show sandbox storage details. + */ +export default class SandboxStorage extends OdsCommand { + static aliases = ['ods:storage']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., zzzz-001)', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.storage.description', 'Show storage details for a specific sandbox'), + '/cli/sandbox.html#b2c-sandbox-storage', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz-001', + '<%= config.bin %> <%= command.id %> zzzz-001 --json', + ]; + + async run(): Promise { + const {args} = await this.parse(SandboxStorage); + const sandboxId = await this.resolveSandboxId(args.sandboxId); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/storage', { + params: { + path: {sandboxId}, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.storage.error', 'Failed to fetch sandbox storage: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as SandboxStorageResponse | undefined)?.data; + if (!data || Object.keys(data).length === 0) { + this.log(t('commands.sandbox.storage.noData', 'No storage data was returned for this sandbox.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as SandboxStorageResponse; + } + + this.printStorage(data); + return data; + } + + private printStorage(storage: SandboxStorageModel): void { + console.log('Sandbox Storage'); + console.log('───────────────'); + console.log('Filesystem Total (MB) Used (MB) Used (%)'); + console.log('──────────────────────── ────────── ───────── ────────'); + + for (const [name, usage] of Object.entries(storage)) { + const total = usage?.spaceTotal ?? '-'; + const used = usage?.spaceUsed ?? '-'; + const percentage = usage?.percentageUsed ?? '-'; + + console.log( + `${name.padEnd(24)} ${String(total).padStart(10)} ${String(used).padStart(9)} ${String(percentage).padStart(8)}`, + ); + } + } +} diff --git a/packages/b2c-cli/test/commands/sandbox/realm/configuration.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/configuration.test.ts new file mode 100644 index 00000000..7db836e6 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/configuration.test.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 {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxRealmConfiguration from '../../../../src/commands/sandbox/realm/configuration.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox realm configuration', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record, args: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxRealmConfiguration as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /realms/{realm}/configuration with provided realm', async () => { + const command = await setupCommand({}, {realm: 'zzzz'}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return {data: {data: {enabled: true}}}; + }, + }); + + const result = await runSilent(() => command.run()); + expect(requestUrl).to.equal('/realms/{realm}/configuration'); + expect(requestOptions).to.have.nested.property('params.path.realm', 'zzzz'); + expect(result).to.deep.equal({enabled: true}); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {realm: 'zzzz'}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + + const response = {data: {data: {enabled: false}}}; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(response.data); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/usages.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/usages.test.ts new file mode 100644 index 00000000..56ff6c1f --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/usages.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxRealmUsages from '../../../../src/commands/sandbox/realm/usages.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any; POST: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox realm usages', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxRealmUsages as any, config, flags, {}); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /realms/usages with provided realms', async () => { + const command = await setupCommand({ + realm: ['zzzz', 'yyyy'], + from: '2026-01-01', + to: '2026-01-31', + 'detailed-report': true, + }); + + sinon.stub(command as any, 'jsonEnabled').returns(false); + + let requestBody: any; + stubOdsClient(command, { + async POST(url: string, options: any) { + expect(url).to.equal('/realms/usages'); + requestBody = options.body; + return {data: {data: [{realmName: 'zzzz', realmUsage: {activeSandboxes: 1}}]}}; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(requestBody).to.deep.equal({ + from: '2026-01-01', + to: '2026-01-31', + realms: ['zzzz', 'yyyy'], + detailedReport: true, + }); + expect(result).to.deep.equal([{realmName: 'zzzz', realmUsage: {activeSandboxes: 1}}]); + }); + + it('discovers realms from /me when no realm flag is provided', async () => { + const command = await setupCommand({}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + + const getStub = sinon.stub().resolves({data: {data: {realms: ['zzzz', 'yyyy']}}}); + const postStub = sinon.stub().resolves({data: {data: [{realmName: 'zzzz'}, {realmName: 'yyyy'}]}}); + + stubOdsClient(command, { + GET: getStub, + POST: postStub, + }); + + await runSilent(() => command.run()); + + expect(getStub.calledOnceWithExactly('/me', {})).to.be.true; + expect(postStub.calledOnce).to.be.true; + expect(postStub.firstCall.args[1].body.realms).to.deep.equal(['zzzz', 'yyyy']); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/settings.test.ts b/packages/b2c-cli/test/commands/sandbox/settings.test.ts new file mode 100644 index 00000000..a726b34b --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/settings.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxSettingsCommand from '../../../src/commands/sandbox/settings.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox settings', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record, args: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxSettingsCommand as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /sandboxes/{sandboxId}/settings with resolved sandbox id', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + ocapi: [{client_id: 'client-a', resources: []}], + webdav: [{client_id: 'client-a', permissions: []}], + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/settings'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(result).to.deep.equal({ + ocapi: [{client_id: 'client-a', resources: []}], + webdav: [{client_id: 'client-a', permissions: []}], + }); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const response = { + data: { + data: { + ocapi: [], + webdav: [], + }, + }, + }; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(response.data); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/storage.test.ts b/packages/b2c-cli/test/commands/sandbox/storage.test.ts new file mode 100644 index 00000000..1ffd5a64 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/storage.test.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 {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxStorage from '../../../src/commands/sandbox/storage.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox storage', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record, args: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxStorage as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /sandboxes/{sandboxId}/storage with resolved sandbox id', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + impex: {spaceTotal: 1000, spaceUsed: 500, percentageUsed: 50}, + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/storage'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(result).to.deep.equal({ + impex: {spaceTotal: 1000, spaceUsed: 500, percentageUsed: 50}, + }); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const response = { + data: { + data: { + logs: {spaceTotal: 200, spaceUsed: 20, percentageUsed: 10}, + }, + }, + }; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(response.data); + }); +}); From 02a6ad8edc4fc1d91615e1dd35bd69c837671d5f Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Tue, 31 Mar 2026 20:12:18 +0530 Subject: [PATCH 2/3] fixing lint --- .../test/commands/sandbox/realm/list.test.ts | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts index 7ccbf166..5a3e09d9 100644 --- a/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts @@ -54,31 +54,12 @@ describe('sandbox realm list', () => { it('discovers realms from /me when no realm argument is provided', async () => { const command = await setupCommand({json: true}, {}); - const getStub = sinon.stub(); - - getStub.callsFake(async (url: string, options: any) => { - if (url === '/me') { - return { - data: { - data: { - realms: ['zzza', 'zzzb'], - }, - }, - }; - } - - if (url === '/realms/{realm}/configuration') { - const realmId = options.params.path.realm; - return { - data: { - data: { - enabled: realmId === 'zzza', - }, - }, - }; - } - - throw new Error(`Unexpected URL: ${url}`); + const getStub = sinon.stub().resolves({ + data: { + data: { + realms: ['zzza', 'zzzb'], + }, + }, }); stubOdsClient(command, {GET: getStub}); @@ -87,28 +68,14 @@ describe('sandbox realm list', () => { expect(result.realms).to.have.lengthOf(2); expect(result.realms[0].realmId).to.equal('zzza'); - expect(result.realms[0].configuration?.enabled).to.equal(true); expect(result.realms[1].realmId).to.equal('zzzb'); - expect(result.realms[1].configuration?.enabled).to.equal(false); + expect(getStub.calledOnceWithExactly('/me', {})).to.be.true; }); - it('fetches configuration for a specific realm when argument is provided', async () => { + it('returns specific realm when argument is provided', async () => { const command = await setupCommand({json: true}, {realm: 'zzzz'}); - const getStub = sinon.stub().callsFake(async (url: string, options: any) => { - if (url === '/realms/{realm}/configuration') { - expect(options).to.have.nested.property('params.path.realm', 'zzzz'); - return { - data: { - data: { - enabled: true, - }, - }, - }; - } - - throw new Error(`Unexpected URL: ${url}`); - }); + const getStub = sinon.stub(); stubOdsClient(command, {GET: getStub}); @@ -116,6 +83,6 @@ describe('sandbox realm list', () => { expect(result.realms).to.have.lengthOf(1); expect(result.realms[0].realmId).to.equal('zzzz'); - expect(result.realms[0].configuration?.enabled).to.equal(true); + expect(getStub.called).to.be.false; }); }); From 32043736ae256ae4d7a295866e1641720aed4938 Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Sat, 4 Apr 2026 00:04:23 +0530 Subject: [PATCH 3/3] Keeping realm topic only under ODS --- .changeset/ods-sandbox-management.md | 7 ++ docs/cli/sandbox.md | 74 +++++++++---------- .../commands/sandbox/realm/configuration.ts | 2 +- .../b2c-cli/src/commands/sandbox/realm/get.ts | 2 +- .../src/commands/sandbox/realm/list.ts | 2 +- .../src/commands/sandbox/realm/update.ts | 2 +- .../src/commands/sandbox/realm/usage.ts | 2 +- .../src/commands/sandbox/realm/usages.ts | 2 +- 8 files changed, 50 insertions(+), 43 deletions(-) create mode 100644 .changeset/ods-sandbox-management.md diff --git a/.changeset/ods-sandbox-management.md b/.changeset/ods-sandbox-management.md new file mode 100644 index 00000000..2c6327da --- /dev/null +++ b/.changeset/ods-sandbox-management.md @@ -0,0 +1,7 @@ +--- +'@salesforce/b2c-cli': minor +--- + +Add ODS sandbox management enhancements, including sandbox storage and settings commands, multi-realm usage reporting, and dedicated realm configuration support. + +Keep sandbox realm workflows under `b2c sandbox realm` / `b2c ods realm` by removing top-level `b2c realm` aliases. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 6f0258b6..1743af2b 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -1038,27 +1038,27 @@ For the complete response including all metadata, use the `--json` flag. ## Realm-Level Commands -Realm commands operate at the **realm** level rather than on an individual sandbox. They are available as both `realm` topic commands and as `sandbox realm` subcommands: +Realm commands operate at the **realm** level rather than on an individual sandbox. Use them under the existing sandbox topics: -- `b2c realm list` (`b2c sandbox realm list`) -- `b2c realm configuration` (`b2c sandbox realm configuration`) -- `b2c realm get` (`b2c sandbox realm get`) -- `b2c realm update` (`b2c sandbox realm update`) -- `b2c realm usage` (`b2c sandbox realm usage`) -- `b2c realm usages` (`b2c sandbox realm usages`) +- `b2c sandbox realm list` (or `b2c ods realm list`) +- `b2c sandbox realm configuration` (or `b2c ods realm configuration`) +- `b2c sandbox realm get` (or `b2c ods realm get`) +- `b2c sandbox realm update` (or `b2c ods realm update`) +- `b2c sandbox realm usage` (or `b2c ods realm usage`) +- `b2c sandbox realm usages` (or `b2c ods realm usages`) ### Required Access for Realm Commands -To run `b2c realm` commands, your user or API client must have **realm‑level access** in Account Manager (typically a role ending in `_sbx` for sandbox management). +To run `b2c sandbox realm` (or `b2c ods realm`) commands, your user or API client must have **realm‑level access** in Account Manager (typically a role ending in `_sbx` for sandbox management). -### b2c realm list +### b2c sandbox realm list List realms eligible for sandbox management. #### Usage ```bash -b2c realm list [REALM] +b2c sandbox realm list [REALM] ``` #### Arguments @@ -1071,25 +1071,25 @@ b2c realm list [REALM] ```bash # List all realms you can manage -b2c realm list +b2c sandbox realm list # List a single realm -b2c realm list zzzz +b2c sandbox realm list zzzz # JSON output -b2c realm list --json +b2c sandbox realm list --json ``` When `REALM` is omitted, the command discovers realms from the `/me` endpoint. -### b2c realm configuration +### b2c sandbox realm configuration Get sandbox configuration for a specific realm. #### Usage ```bash -b2c realm configuration +b2c sandbox realm configuration ``` #### Arguments @@ -1102,22 +1102,22 @@ b2c realm configuration ```bash # Get realm sandbox configuration -b2c realm configuration zzzz +b2c sandbox realm configuration zzzz # JSON output -b2c realm configuration zzzz --json +b2c sandbox realm configuration zzzz --json ``` When not using `--json`, the command prints configuration details such as emails, sandbox limits, TTL values, and start/stop schedulers. -### b2c realm get +### b2c sandbox realm get Get detailed information about a specific realm, including configuration. #### Usage ```bash -b2c realm get +b2c sandbox realm get ``` #### Arguments @@ -1130,10 +1130,10 @@ b2c realm get ```bash # Get realm details -b2c realm get zzzz +b2c sandbox realm get zzzz # JSON output (includes configuration and account details when available) -b2c realm get zzzz --json +b2c sandbox realm get zzzz --json ``` #### Output @@ -1150,14 +1150,14 @@ The command prints: - Whether local users are allowed - Start/stop scheduler definitions (as JSON) when present -### b2c realm update +### b2c sandbox realm update Update realm‑level sandbox configuration for TTL and start/stop schedulers. #### Usage ```bash -b2c realm update [FLAGS] +b2c sandbox realm update [FLAGS] ``` #### Arguments @@ -1186,27 +1186,27 @@ The scheduler flags expect a JSON value or the literal string `"null"`: ```bash # Set max TTL to unlimited and default TTL to 24 hours -b2c realm update zzzz --max-sandbox-ttl 0 --default-sandbox-ttl 24 +b2c sandbox realm update zzzz --max-sandbox-ttl 0 --default-sandbox-ttl 24 # Configure weekday start/stop schedules -b2c realm update zzzz \ +b2c sandbox realm update zzzz \ --start-scheduler '{"weekdays":["MONDAY","TUESDAY"],"time":"08:00:00Z"}' \ --stop-scheduler '{"weekdays":["MONDAY","TUESDAY"],"time":"19:00:00Z"}' # Remove an existing stop scheduler -b2c realm update zzzz --stop-scheduler "null" +b2c sandbox realm update zzzz --stop-scheduler "null" ``` If no update flags are provided, the command fails with a helpful error explaining which flags can be used. -### b2c realm usage +### b2c sandbox realm usage Show usage information for a realm across all sandboxes in that realm. #### Usage ```bash -b2c realm usage [FLAGS] +b2c sandbox realm usage [FLAGS] ``` #### Arguments @@ -1228,13 +1228,13 @@ b2c realm usage [FLAGS] ```bash # Realm usage for a recent window -b2c realm usage zzzz +b2c sandbox realm usage zzzz # Realm usage for a specific range -b2c realm usage zzzz --from 2024-01-01 --to 2024-01-31 +b2c sandbox realm usage zzzz --from 2024-01-01 --to 2024-01-31 # Daily granularity with full JSON response -b2c realm usage zzzz --granularity daily --detailed-report --json +b2c sandbox realm usage zzzz --granularity daily --detailed-report --json ``` When not using `--json`, the command prints a summary including: @@ -1246,14 +1246,14 @@ When not using `--json`, the command prints a summary including: If detailed usage is available, it prints a hint to re-run with `--json` for the full structure. If no usage data is returned for the requested period, it prints a friendly message instead of failing. -### b2c realm usages +### b2c sandbox realm usages Show usage information for multiple realms in one request. #### Usage ```bash -b2c realm usages [FLAGS] +b2c sandbox realm usages [FLAGS] ``` #### Flags @@ -1271,16 +1271,16 @@ If `--realm` is omitted, the command auto-discovers realms from `/me` and querie ```bash # Usage for all realms available to the current user -b2c realm usages +b2c sandbox realm usages # Usage for two specific realms -b2c realm usages --realm zzzz --realm yyyy +b2c sandbox realm usages --realm zzzz --realm yyyy # Usage for comma-separated realms and date range -b2c realm usages --realm zzzz,yyyy --from 2024-01-01 --to 2024-01-31 +b2c sandbox realm usages --realm zzzz,yyyy --from 2024-01-01 --to 2024-01-31 # Detailed report in JSON -b2c realm usages --detailed-report --json +b2c sandbox realm usages --detailed-report --json ``` When not using `--json`, the command prints one row per realm with summary metrics such as: diff --git a/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts index 57c23041..0476338e 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts @@ -16,7 +16,7 @@ type RealmConfigurationResponse = OdsComponents['schemas']['RealmConfigurationRe * Get realm sandbox configuration. */ export default class SandboxRealmConfiguration extends OdsCommand { - static aliases = ['ods:realm:configuration', 'realm:configuration']; + static aliases = ['ods:realm:configuration']; static args = { realm: Args.string({ diff --git a/packages/b2c-cli/src/commands/sandbox/realm/get.ts b/packages/b2c-cli/src/commands/sandbox/realm/get.ts index c30fde6d..efca2b89 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/get.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/get.ts @@ -17,7 +17,7 @@ type RealmModel = OdsComponents['schemas']['RealmModel']; * Get details of a specific realm. */ export default class SandboxRealmGet extends OdsCommand { - static aliases = ['ods:realm:get', 'realm:get']; + static aliases = ['ods:realm:get']; static args = { realm: Args.string({ diff --git a/packages/b2c-cli/src/commands/sandbox/realm/list.ts b/packages/b2c-cli/src/commands/sandbox/realm/list.ts index fbd91d4a..fc67d3dd 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/list.ts @@ -21,7 +21,7 @@ interface RealmListResponse { * List realms eligible for sandbox management. */ export default class SandboxRealmList extends OdsCommand { - static aliases = ['ods:realm:list', 'realm:list']; + static aliases = ['ods:realm:list']; static args = { realm: Args.string({ diff --git a/packages/b2c-cli/src/commands/sandbox/realm/update.ts b/packages/b2c-cli/src/commands/sandbox/realm/update.ts index 6bd709d8..e7ba6e8c 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/update.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/update.ts @@ -16,7 +16,7 @@ type RealmConfigurationResponse = OdsComponents['schemas']['RealmConfigurationRe * Update realm-level ODS configuration (TTL and schedulers). */ export default class SandboxRealmUpdate extends OdsCommand { - static aliases = ['ods:realm:update', 'realm:update']; + static aliases = ['ods:realm:update']; static args = { realm: Args.string({ diff --git a/packages/b2c-cli/src/commands/sandbox/realm/usage.ts b/packages/b2c-cli/src/commands/sandbox/realm/usage.ts index 822c9c09..6077262f 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/usage.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/usage.ts @@ -15,7 +15,7 @@ type RealmUsageModel = OdsComponents['schemas']['RealmUsageModel']; * Show realm-level usage information. */ export default class SandboxRealmUsage extends OdsCommand { - static aliases = ['ods:realm:usage', 'realm:usage']; + static aliases = ['ods:realm:usage']; static args = { realm: Args.string({ diff --git a/packages/b2c-cli/src/commands/sandbox/realm/usages.ts b/packages/b2c-cli/src/commands/sandbox/realm/usages.ts index b0261fa5..61e8b7df 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/usages.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/usages.ts @@ -16,7 +16,7 @@ type MultiRealmUsageResponse = OdsComponents['schemas']['MultiRealmUsageResponse * Show usage information for multiple realms. */ export default class SandboxRealmUsages extends OdsCommand { - static aliases = ['ods:realm:usages', 'realm:usages']; + static aliases = ['ods:realm:usages']; static description = withDocs( t('commands.realm.usages.description', 'Show usage information for multiple realms'),