diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index f766271c..e7a7ced0 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -401,3 +401,427 @@ b2c sandbox delete zzzv_123 --force - The command will prompt for confirmation unless `--force` is used - Deleted sandboxes cannot be recovered + +--- + +## b2c sandbox reset + +Reset an on-demand sandbox to a clean state. This clears **all data and code** in the sandbox but preserves its configuration (realm, profile, schedulers, etc.). + +### Usage + +```bash +b2c sandbox reset +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--wait`, `-w` | Wait for the sandbox to reach `started` state after reset | `false` | +| `--poll-interval` | Polling interval in seconds when using `--wait` | `10` | +| `--timeout` | Maximum time to wait in seconds when using `--wait` (`0` for no timeout) | `600` | +| `--force`, `-f` | Skip confirmation prompt | `false` | + +### Examples + +```bash +# Trigger a reset and return immediately +b2c sandbox reset zzzv-123 + +# Reset and wait for the sandbox to return to started state +b2c sandbox reset zzzv-123 --wait + +# Reset with a custom polling interval and timeout +b2c sandbox reset zzzv-123 --wait --poll-interval 15 --timeout 900 + +# Reset without confirmation +b2c sandbox reset zzzv-123 --force + +# Output operation details as JSON +b2c sandbox reset zzzv-123 --json +``` + +### Notes + +- Reset is **destructive**: it permanently removes all data and code in the sandbox. +- When `--wait` is used, the command periodically polls the sandbox and logs state transitions as `[s] State: ` until it reaches `started` or the timeout is hit. + +--- + +## b2c sandbox usage + +Show usage information for a specific sandbox over a date range. + +### Usage + +```bash +b2c sandbox usage [--from ] [--to ] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +### Flags + +| Flag | Description | +|------|-------------| +| `--from` | Start date for usage data (ISO 8601, e.g., `2024-01-01`) | +| `--to` | End date for usage data (ISO 8601, e.g., `2024-01-31`) | + +If `--from` / `--to` are omitted, the API will use its own defaults (typically a recent window). + +### Examples + +```bash +# Show recent usage for a sandbox +b2c sandbox usage zzzz-001 + +# Show usage for a specific period +b2c sandbox usage zzzz-001 --from 2024-01-01 --to 2024-01-31 + +# Get raw usage response as JSON (includes detailed fields) +b2c sandbox usage zzzz-001 --from 2024-01-01 --to 2024-01-31 --json +``` + +### Output + +When not using `--json`, the command prints a concise summary: + +- Total sandbox seconds +- Minutes up / minutes down +- Minutes up by profile (if available) + +If detailed usage data is present (granular history, profiles, etc.), the command prints a hint to re-run with `--json` to inspect the full structure. If no usage data is returned for the requested period, it prints a friendly message instead of failing. + +--- + +## Sandbox Aliases + +Sandbox aliases let you access a sandbox via a custom hostname instead of the default instance hostname. + +Alias commands are available both under the `sandbox` topic and the legacy `ods` aliases: + +- `b2c sandbox alias create` (`b2c ods alias:create`) +- `b2c sandbox alias list` (`b2c ods alias:list`) +- `b2c sandbox alias delete` (`b2c ods alias:delete`) + +### b2c sandbox alias create + +Create a hostname alias for a sandbox. + +#### Usage + +```bash +b2c sandbox alias create [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | +| `HOSTNAME` | Hostname alias to register (e.g., `my-store.example.com`) | Yes | + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--unique`, `-u` | Make the alias unique (required for Let’s Encrypt certificates) | `false` | +| `--letsencrypt` | Request a Let’s Encrypt certificate (requires `--unique`) | `false` | +| `--no-open` | Do not open the registration URL in a browser for non‑unique aliases | `false` | + +#### Examples + +```bash +# Simple alias +b2c sandbox alias create zzzv-123 my-store.example.com + +# Unique alias (suitable for TLS) +b2c sandbox alias create zzzv-123 secure-store.example.com --unique + +# Unique alias with Let’s Encrypt certificate +b2c sandbox alias create zzzv-123 secure-store.example.com --unique --letsencrypt + +# Create alias but handle registration manually +b2c sandbox alias create zzzv-123 my-store.example.com --no-open + +# Output alias details as JSON +b2c sandbox alias create zzzv-123 my-store.example.com --json +``` + +#### Behavior + +- For **unique** aliases, the API may return a DNS TXT record that must be added to your DNS provider before the alias becomes active. +- For **non‑unique** aliases, the API returns a registration URL; the CLI opens this URL in a browser by default (unless `--no-open` is set) to complete registration. + +### b2c sandbox alias list + +List or inspect aliases for a sandbox. + +#### Usage + +```bash +b2c sandbox alias list [--alias-id ] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--alias-id` | Specific alias ID to retrieve; if omitted, lists all aliases | + +#### Examples + +```bash +# List all aliases for a sandbox +b2c sandbox alias list zzzv-123 + +# Get details for a single alias +b2c sandbox alias list zzzv-123 --alias-id some-alias-uuid + +# Output as JSON +b2c sandbox alias list zzzv-123 --json +``` + +When listing multiple aliases without `--json`, the command prints a table with: + +- Alias ID +- Hostname +- Status +- Whether the alias is unique +- DNS verification record (if any) + +### b2c sandbox alias delete + +Delete a sandbox alias. + +#### Usage + +```bash +b2c sandbox alias delete [--force] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | +| `ALIASID` | Alias ID to delete | Yes | + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--force`, `-f` | Skip confirmation prompt | `false` | + +#### Examples + +```bash +# Delete an alias with confirmation +b2c sandbox alias delete zzzv-123 alias-uuid-here + +# Delete an alias without confirmation +b2c sandbox alias delete zzzv-123 alias-uuid-here --force + +# Delete and get structured result +b2c sandbox alias delete zzzv-123 alias-uuid-here --json +``` + +--- + +## 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: + +- `b2c realm list` (`b2c sandbox realm list`) +- `b2c realm get` (`b2c sandbox realm get`) +- `b2c realm update` (`b2c sandbox realm update`) +- `b2c realm usage` (`b2c sandbox realm usage`) + +### 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). + +### b2c realm list + +List realms eligible for sandbox management, optionally including a simple usage summary. + +#### Usage + +```bash +b2c realm list [REALM] [--show-usage] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `REALM` | Specific realm ID (four-letter ID) to get details for | No | + +#### Examples + +```bash +# List all realms you can manage +b2c realm list + +# List a single realm +b2c realm list zzzz + +# JSON output +b2c realm list --json +``` + +When `REALM` is omitted, the command discovers realms from the `/me` endpoint and then fetches configuration for each. + +### b2c realm get + +Get detailed information about a specific realm, including configuration. + +#### Usage + +```bash +b2c realm get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `REALM` | Realm ID (four-letter ID) | Yes | + +#### Examples + +```bash +# Get realm details +b2c realm get zzzz + +# JSON output (includes configuration and account details when available) +b2c realm get zzzz --json +``` + +#### Output + +The command prints: + +- Realm ID, name, and enabled status +- Realm configuration, including: + - Notification emails (if configured) + - Whether limits are enabled + - Total number of sandboxes + - Max sandbox TTL (displays `0` when TTL is effectively unlimited) + - Default sandbox TTL + - Whether local users are allowed + - Start/stop scheduler definitions (as JSON) when present + +### b2c realm update + +Update realm‑level sandbox configuration for TTL and start/stop schedulers. + +#### Usage + +```bash +b2c realm update [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `REALM` | Realm ID (four-letter ID) to update | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--max-sandbox-ttl` | Maximum sandbox TTL in hours (`0` for unlimited, subject to quotas) | +| `--default-sandbox-ttl` | Default sandbox TTL in hours when no TTL is specified at creation | +| `--start-scheduler` | Start schedule JSON for sandboxes in this realm (use `"null"` to remove) | +| `--stop-scheduler` | Stop schedule JSON for sandboxes in this realm (use `"null"` to remove) | + +The scheduler flags expect a JSON value or the literal string `"null"`: + +```bash +--start-scheduler '{"weekdays":["MONDAY"],"time":"08:00:00Z"}' +--stop-scheduler "null" # remove existing stop scheduler +``` + +#### Examples + +```bash +# Set max TTL to unlimited and default TTL to 24 hours +b2c realm update zzzz --max-sandbox-ttl 0 --default-sandbox-ttl 24 + +# Configure weekday start/stop schedules +b2c 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" +``` + +If no update flags are provided, the command fails with a helpful error explaining which flags can be used. + +### b2c realm usage + +Show usage information for a realm across all sandboxes in that realm. + +#### Usage + +```bash +b2c realm usage [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `REALM` | Realm ID (four-letter ID) | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--from` | Earliest date to include in usage (ISO 8601; API defaults to ~30 days ago if omitted) | +| `--to` | Latest date to include in usage (ISO 8601; API defaults to today if omitted) | +| `--granularity` | Data granularity (`daily`, `weekly`, or `monthly`) | +| `--detailed-report` | Include detailed usage information in the response | + +#### Examples + +```bash +# Realm usage for a recent window +b2c realm usage zzzz + +# Realm usage for a specific range +b2c 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 +``` + +When not using `--json`, the command prints a summary including: + +- Active / created / deleted sandbox counts +- Minutes up / minutes down +- Sandbox seconds +- Minutes up by profile (if present) + +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. + diff --git a/packages/b2c-cli/src/commands/sandbox/alias/create.ts b/packages/b2c-cli/src/commands/sandbox/alias/create.ts new file mode 100644 index 00000000..9610712e --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/alias/create.ts @@ -0,0 +1,127 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; +import open from 'open'; + +type SandboxAliasModel = OdsComponents['schemas']['SandboxAliasModel']; + +/** + * Command to create a sandbox alias. + */ +export default class SandboxAliasCreate extends OdsCommand { + static aliases = ['ods:alias:create']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', + required: true, + }), + hostname: Args.string({ + description: 'Hostname alias to register (e.g., my-store.example.com)', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.alias.create.description', 'Create a hostname alias for a sandbox'), + '/cli/sandbox.html#b2c-sandbox-alias-create', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzv-123 my-store.example.com', + '<%= config.bin %> <%= command.id %> zzzv-123 secure-store.example.com --unique', + '<%= config.bin %> <%= command.id %> zzzv-123 secure-store.example.com --unique --letsencrypt', + '<%= config.bin %> <%= command.id %> zzzv-123 my-store.example.com --json', + ]; + + static flags = { + unique: Flags.boolean({ + char: 'u', + description: "Make the alias unique (required for Let's Encrypt certificates)", + default: false, + }), + letsencrypt: Flags.boolean({ + description: "Request a Let's Encrypt certificate for this alias (requires --unique)", + default: false, + dependsOn: ['unique'], + }), + 'no-open': Flags.boolean({ + description: 'Do not open registration URL in browser (for non-unique aliases)', + default: false, + }), + }; + + async run(): Promise { + const {sandboxId, hostname} = this.args; + const {unique, letsencrypt, 'no-open': noOpen} = this.flags; + + const resolvedSandboxId = await this.resolveSandboxId(sandboxId); + + this.log( + t('commands.sandbox.alias.create.creating', 'Creating alias {{hostname}} for sandbox {{sandboxId}}...', { + hostname, + sandboxId, + }), + ); + + const result = await this.odsClient.POST('/sandboxes/{sandboxId}/aliases', { + params: { + path: {sandboxId: resolvedSandboxId}, + }, + body: { + name: hostname, + unique, + requestLetsEncryptCertificate: letsencrypt, + }, + }); + + if (!result.data?.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error( + t('commands.sandbox.alias.create.error', 'Failed to create alias: {{message}}', { + message, + }), + ); + } + + const alias = result.data.data as SandboxAliasModel; + + if (!this.jsonEnabled()) { + this.log(t('commands.sandbox.alias.create.success', 'Alias created successfully')); + this.log(''); + this.log(`ID: ${alias.id}`); + this.log(`Hostname: ${alias.name}`); + this.log(`Status: ${alias.status}`); + + if (unique && alias.domainVerificationRecord) { + this.log(''); + this.log(t('commands.sandbox.alias.create.verification', '⚠️ DNS Verification Required:')); + this.log( + t( + 'commands.sandbox.alias.create.verification_instructions', + 'Add this TXT record to your DNS configuration:', + ), + ); + this.log(` ${alias.domainVerificationRecord}`); + this.log(''); + this.log(t('commands.sandbox.alias.create.verification_wait', 'The alias will activate after DNS propagation')); + } + + if (!unique && alias.registration && !noOpen) { + this.log(''); + this.log(t('commands.sandbox.alias.create.registration', 'Opening alias registration in browser...')); + await open(alias.registration); + } + } + + return alias; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/alias/delete.ts b/packages/b2c-cli/src/commands/sandbox/alias/delete.ts new file mode 100644 index 00000000..6799c209 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/alias/delete.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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; +import {confirm} from '@inquirer/prompts'; + +/** + * Command to delete a sandbox alias. + */ +export default class SandboxAliasDelete extends OdsCommand { + static aliases = ['ods:alias:delete']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', + required: true, + }), + aliasId: Args.string({ + description: 'Alias ID to delete', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.alias.delete.description', 'Delete a hostname alias from a sandbox'), + '/cli/sandbox.html#b2c-sandbox-alias-delete', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzv-123 alias-uuid-here', + '<%= config.bin %> <%= command.id %> zzzv-123 alias-uuid-here --force', + '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 alias-uuid-here --json', + ]; + + static flags = { + force: Flags.boolean({ + char: 'f', + description: 'Skip confirmation prompt', + default: false, + }), + }; + + async run(): Promise<{success: boolean; message: string}> { + const {sandboxId, aliasId} = this.args; + const {force} = this.flags; + + const resolvedSandboxId = await this.resolveSandboxId(sandboxId); + + // Confirmation prompt (skip if --force or --json) + if (!force && !this.jsonEnabled()) { + const confirmed = await confirm({ + message: t('commands.sandbox.alias.delete.confirm', 'Delete alias {{aliasId}}?', {aliasId}), + default: false, + }); + + if (!confirmed) { + this.log(t('commands.sandbox.alias.delete.cancelled', 'Delete cancelled')); + return {success: false, message: 'Cancelled by user'}; + } + } + + this.log( + t('commands.sandbox.alias.delete.deleting', 'Deleting alias {{aliasId}} from sandbox {{sandboxId}}...', { + aliasId, + sandboxId, + }), + ); + + const result = await this.odsClient.DELETE('/sandboxes/{sandboxId}/aliases/{sandboxAliasId}', { + params: { + path: {sandboxId: resolvedSandboxId, sandboxAliasId: aliasId}, + }, + }); + + if (result.response?.status !== 404 && result.error) { + const message = getApiErrorMessage(result.error, result.response); + this.error( + t('commands.sandbox.alias.delete.error', 'Failed to delete alias: {{message}}', { + message, + }), + ); + } + + const message = t('commands.sandbox.alias.delete.success', 'Alias deleted successfully'); + this.log(message); + + return {success: true, message}; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/alias/list.ts b/packages/b2c-cli/src/commands/sandbox/alias/list.ts new file mode 100644 index 00000000..d863a027 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/alias/list.ts @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {OdsCommand, TableRenderer} 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']; + +/** + * Command to list sandbox aliases. + */ +export default class SandboxAliasList extends OdsCommand { + static aliases = ['ods:alias:list']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.alias.list.description', 'List all hostname aliases for a sandbox'), + '/cli/sandbox.html#b2c-sandbox-alias-list', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', + '<%= config.bin %> <%= command.id %> zzzv-123', + '<%= config.bin %> <%= command.id %> zzzv-123 --alias-id some-alias-uuid', + '<%= config.bin %> <%= command.id %> zzzv-123 --json', + ]; + + static flags = { + 'alias-id': Flags.string({ + description: 'Specific alias ID to retrieve (if omitted, lists all aliases)', + required: false, + }), + }; + + async run(): Promise { + const {sandboxId} = this.args; + const {'alias-id': aliasId} = this.flags; + + const resolvedSandboxId = await this.resolveSandboxId(sandboxId); + + // If alias ID provided, get specific alias; otherwise list all + if (aliasId) { + return this.showAlias(resolvedSandboxId, aliasId); + } + return this.listAllAliases(resolvedSandboxId); + } + + private async listAllAliases(sandboxId: string): Promise { + this.log( + t('commands.sandbox.alias.list.fetching', 'Fetching aliases for sandbox {{sandboxId}}...', { + sandboxId: this.args.sandboxId, + }), + ); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/aliases', { + params: { + path: {sandboxId}, + }, + }); + + if (!result.data?.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error( + t('commands.sandbox.alias.list.error', 'Failed to fetch aliases: {{message}}', { + message, + }), + ); + } + + const aliases = (result.data?.data ?? []) as SandboxAliasModel[]; + + if (!this.jsonEnabled()) { + if (aliases.length === 0) { + this.log(t('commands.sandbox.alias.list.no_aliases', 'No aliases found')); + } else { + this.log(t('commands.sandbox.alias.list.count', 'Found {{count}} alias(es):', {count: aliases.length})); + const columns = { + id: { + header: 'Alias ID', + get: (row: SandboxAliasModel) => 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']); + } + } + + return aliases; + } + + private async showAlias(sandboxId: string, aliasId: string): Promise { + this.log( + t('commands.sandbox.alias.list.fetching_one', 'Fetching alias {{aliasId}} for sandbox {{sandboxId}}...', { + aliasId, + sandboxId, + }), + ); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/aliases/{sandboxAliasId}', { + params: { + path: {sandboxId, sandboxAliasId: aliasId}, + }, + }); + + if (!result.data?.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error( + t('commands.sandbox.alias.list.error_one', 'Failed to fetch alias: {{message}}', { + message, + }), + ); + } + + const alias = result.data.data as SandboxAliasModel; + + if (!this.jsonEnabled()) { + this.log(''); + this.log(t('commands.sandbox.alias.list.alias_details', 'Alias Details:')); + this.log('─'.repeat(60)); + this.log(`ID: ${alias.id}`); + this.log(`Name: ${alias.name}`); + this.log(`Status: ${alias.status}`); + if (alias.unique) { + this.log(`Unique: ${alias.unique}`); + } + if (alias.domainVerificationRecord) { + this.log(`Verification Record: ${alias.domainVerificationRecord}`); + } + if (alias.registration) { + this.log(`Registration URL: ${alias.registration}`); + } + } + + return alias; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/ips.ts b/packages/b2c-cli/src/commands/sandbox/ips.ts new file mode 100644 index 00000000..88ae7f83 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/ips.ts @@ -0,0 +1,108 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../i18n/index.js'; + +type SystemInfoSpec = OdsComponents['schemas']['SystemInfoSpec']; + +type SystemInfoResponse = OdsComponents['schemas']['SystemInfoResponse']; + +/** + * List inbound and outbound IP addresses for ODS sandboxes. + */ +export default class SandboxIps extends OdsCommand { + static aliases = ['ods:ips']; + + static description = withDocs( + t('commands.sandbox.ips.description', 'List inbound and outbound IP addresses for ODS sandboxes'), + '/cli/sandbox.html#b2c-sandbox-ips', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --json', + '<%= config.bin %> <%= command.id %> --realm zzzz', + '<%= config.bin %> <%= command.id %> -r zzzz --json', + ]; + + static flags = { + realm: Flags.string({ + char: 'r', + description: 'Realm ID (four-letter ID) to get IP details for', + }), + } as const; + + async run(): Promise { + const {flags} = await this.parse(SandboxIps); + const host = this.odsHost; + + this.log(t('commands.sandbox.ips.fetching', 'Fetching sandbox IP information from {{host}}...', {host})); + + const result: { + data?: SystemInfoResponse; + error?: unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response?: any; + } = await (flags.realm + ? this.odsClient.GET('/realms/{realm}/system', { + params: {path: {realm: flags.realm}}, + }) + : this.odsClient.GET('/system', {})); + + if (result.error) { + this.error( + t('commands.sandbox.ips.error', 'Failed to fetch sandbox IP information: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as SystemInfoResponse | undefined)?.data; + + if (!data) { + this.log(t('commands.sandbox.ips.noData', 'No system information was returned.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as SystemInfoResponse; + } + + this.printIps(data, flags.realm); + + return data; + } + + private printIps(system: SystemInfoSpec, realm?: string): void { + const context = realm ? t('commands.sandbox.ips.realmLabel', ' (for realm {{realm}})', {realm}) : ''; + + console.log(t('commands.sandbox.ips.inboundHeader', 'Inbound IP addresses{{context}}:', {context})); + for (const ip of system.inboundIps ?? []) { + console.log(` - ${ip}`); + } + + console.log(); + + console.log(t('commands.sandbox.ips.outboundHeader', 'Outbound IP addresses{{context}}:', {context})); + for (const ip of system.outboundIps ?? []) { + console.log(` - ${ip}`); + } + + if (system.sandboxIps && system.sandboxIps.length > 0) { + console.log(); + + console.log(t('commands.sandbox.ips.sandboxHeader', 'Sandbox IP addresses{{context}}:', {context})); + for (const ip of system.sandboxIps) { + console.log(` - ${ip}`); + } + } + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/realm/get.ts b/packages/b2c-cli/src/commands/sandbox/realm/get.ts new file mode 100644 index 00000000..c30fde6d --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/realm/get.ts @@ -0,0 +1,171 @@ +/* + * 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, ux} from '@oclif/core'; +import cliui from 'cliui'; +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 RealmModel = OdsComponents['schemas']['RealmModel']; + +/** + * Get details of a specific realm. + */ +export default class SandboxRealmGet extends OdsCommand { + static aliases = ['ods:realm:get', 'realm:get']; + + static args = { + realm: Args.string({ + description: 'Realm ID (four-letter ID)', + required: true, + }), + }; + + static description = withDocs( + t('commands.realm.get.description', 'Get details of a specific realm'), + '/cli/realm.html#b2c-realm-get', + ); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %> zzzz', '<%= config.bin %> <%= command.id %> zzzz --json']; + + static flags = {} as const; + + async run(): Promise<{ + realm: RealmModel; + configuration?: RealmConfigurationModel; + }> { + const {args} = await this.parse(SandboxRealmGet); + const realm = args.realm; + const host = this.odsHost; + + this.log(t('commands.realm.get.fetching', 'Fetching realm {{realm}} from {{host}}...', {realm, host})); + + // Fetch full realm info (metadata + configuration + accountdetails) + const result = await this.odsClient.GET('/realms/{realm}', { + params: { + path: {realm}, + query: {expand: ['configuration', 'accountdetails']}, + }, + }); + + if (!result.data?.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error( + t('commands.realm.get.error', 'Failed to fetch realm {{realm}}: {{message}}', { + realm, + message, + }), + ); + } + + const realmInfo = result.data.data as RealmModel; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const configuration = (realmInfo as any).configuration as RealmConfigurationModel | undefined; + + const response = {realm: realmInfo, configuration}; + + if (this.jsonEnabled()) { + return response; + } + + this.printRealmDetails(realmInfo, configuration); + + return response; + } + + private addFieldRows(ui: ReturnType, fields: [string, string | undefined][]): void { + 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 printRealmDetails(realm: RealmModel, config?: RealmConfigurationModel): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'Realm Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const realmAny = realm as any; + + const metaFields: [string, string | undefined][] = [ + ['Realm ID', realmAny.id ?? realmAny.realmCode ?? realmAny.realm], + ['Name', realmAny.name], + ['Enabled', realmAny.enabled === undefined ? undefined : String(realmAny.enabled)], + ]; + this.addFieldRows(ui, metaFields); + + // Configuration block (if available via expand) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const configAny = (config as any) ?? realmAny.configuration; + + if (configAny) { + ui.div({text: '', padding: [1, 0, 0, 0]}); + ui.div({text: 'Realm Configuration', padding: [0, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const maxTtlRaw = configAny.sandbox?.sandboxTTL?.maximum as number | undefined; + const maxTtlDisplay = maxTtlRaw === undefined ? undefined : maxTtlRaw >= 2_147_483_647 ? '0' : String(maxTtlRaw); + + const configFields: [string, string | undefined][] = [ + ['Emails', Array.isArray(configAny.emails) ? configAny.emails.join(', ') : undefined], + [ + 'Limits Enabled', + configAny.sandbox?.limitsEnabled === undefined ? undefined : String(configAny.sandbox.limitsEnabled), + ], + [ + 'Total Sandboxes', + configAny.sandbox?.totalNumberOfSandboxes === undefined + ? undefined + : String(configAny.sandbox.totalNumberOfSandboxes), + ], + ['Max Sandbox TTL', maxTtlDisplay], + [ + 'Default Sandbox TTL', + configAny.sandbox?.sandboxTTL?.defaultValue === undefined + ? undefined + : String(configAny.sandbox.sandboxTTL.defaultValue), + ], + [ + 'Local Users Allowed', + configAny.sandbox?.localUsersAllowed === undefined ? undefined : String(configAny.sandbox.localUsersAllowed), + ], + ]; + this.addFieldRows(ui, configFields); + } + + // Schedulers + if (configAny?.sandbox?.startScheduler) { + ui.div( + {text: 'Start Scheduler:', width: 25, padding: [0, 2, 0, 0]}, + { + text: JSON.stringify(configAny.sandbox.startScheduler), + padding: [0, 0, 0, 0], + }, + ); + } + + if (configAny?.sandbox?.stopScheduler) { + ui.div( + {text: 'Stop Scheduler:', width: 25, padding: [0, 2, 0, 0]}, + { + text: JSON.stringify(configAny.sandbox.stopScheduler), + padding: [0, 0, 0, 0], + }, + ); + } + + // Realm usage is now provided by the dedicated `realm usage` command. + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/realm/list.ts b/packages/b2c-cli/src/commands/sandbox/realm/list.ts new file mode 100644 index 00000000..5d9209ed --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/realm/list.ts @@ -0,0 +1,127 @@ +/* + * 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']; + +interface RealmWithUsage { + realmId: string; + configuration?: RealmConfigurationModel; +} + +interface RealmListResponse { + realms: RealmWithUsage[]; +} + +/** + * List realms eligible for sandbox management, optionally including usage. + */ +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', + required: false, + }), + }; + + static description = withDocs( + t('commands.realm.list.description', 'List realms eligible for sandbox management'), + '/cli/realm.html#b2c-realm-list', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --json', + '<%= config.bin %> <%= command.id %> zzzz', + ]; + + async run(): Promise { + const {args} = await this.parse(SandboxRealmList); + const host = this.odsHost; + + this.log(t('commands.realm.list.fetching', 'Fetching realms from {{host}}...', {host})); + + let realmIds: string[]; + + if (args.realm) { + realmIds = [args.realm]; + } else { + // Discover realms from the current user info + const meResult = await this.odsClient.GET('/me', {}); + if (meResult.error || !meResult.data?.data?.realms) { + this.error( + t('commands.realm.list.meError', 'Failed to fetch user realms: {{message}}', { + message: getApiErrorMessage(meResult.error, meResult.response), + }), + ); + } + + realmIds = meResult.data.data.realms ?? []; + } + + const realms: RealmWithUsage[] = []; + + for (const realmId of realmIds) { + // Fetch configuration for each realm + // eslint-disable-next-line no-await-in-loop -- Sequential API calls required + const configResult = await this.odsClient.GET('/realms/{realm}/configuration', { + params: {path: {realm: realmId}}, + }); + + if (configResult.error) { + this.error( + t('commands.realm.list.configError', 'Failed to fetch configuration for realm {{realm}}: {{message}}', { + realm: realmId, + message: getApiErrorMessage(configResult.error, configResult.response), + }), + ); + } + + const realmEntry: RealmWithUsage = { + realmId, + + configuration: (configResult.data?.data as RealmConfigurationModel | undefined) ?? undefined, + }; + + realms.push(realmEntry); + } + + const response: RealmListResponse = {realms}; + + if (this.jsonEnabled()) { + return response; + } + + if (realms.length === 0) { + this.log(t('commands.realm.list.none', 'No realms found for sandbox management.')); + return response; + } + + // Human-readable output: simple table-like listing + + console.log('Realm Enabled'); + + console.log('───── ───────'); + + for (const realm of realms) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const configAny = realm.configuration as any; + const enabled = configAny?.enabled ?? false; + + console.log(`${realm.realmId.padEnd(5)} ${String(enabled).padEnd(7)}`); + } + + return response; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/realm/update.ts b/packages/b2c-cli/src/commands/sandbox/realm/update.ts new file mode 100644 index 00000000..6bd709d8 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/realm/update.ts @@ -0,0 +1,158 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +type RealmConfigurationUpdateRequestModel = OdsComponents['schemas']['RealmConfigurationUpdateRequestModel']; +type RealmConfigurationResponse = OdsComponents['schemas']['RealmConfigurationResponse']; + +/** + * Update realm-level ODS configuration (TTL and schedulers). + */ +export default class SandboxRealmUpdate extends OdsCommand { + static aliases = ['ods:realm:update', 'realm:update']; + + static args = { + realm: Args.string({ + description: 'Realm ID (four-letter ID) to update', + required: true, + }), + }; + + static description = withDocs( + t('commands.realm.update.description', 'Update realm configuration'), + '/cli/realm.html#b2c-realm-update', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz --max-sandbox-ttl 72', + '<%= config.bin %> <%= command.id %> zzzz --default-sandbox-ttl 24', + '<%= config.bin %> <%= command.id %> zzzz --start-scheduler \'{"weekdays":["MONDAY"],"time":"08:00:00Z"}\'', + '<%= config.bin %> <%= command.id %> zzzz --stop-scheduler "null"', + ]; + + static flags = { + 'max-sandbox-ttl': Flags.integer({ + description: 'Maximum sandbox TTL in hours (0 for unlimited, subject to quotas)', + }), + 'default-sandbox-ttl': Flags.integer({ + description: 'Default sandbox TTL in hours when no TTL is specified at creation', + }), + 'start-scheduler': Flags.string({ + description: + 'Start schedule JSON for sandboxes in this realm (use "null" to remove). Format: {"weekdays":[...],"time":"..."}', + }), + 'stop-scheduler': Flags.string({ + description: + 'Stop schedule JSON for sandboxes in this realm (use "null" to remove). Format: {"weekdays":[...],"time":"..."}', + }), + } as const; + + async run(): Promise { + const {args, flags} = await this.parse(SandboxRealmUpdate); + + const realm = args.realm; + const host = this.odsHost; + + const hasAnyUpdateFlag = + flags['max-sandbox-ttl'] !== undefined || + flags['default-sandbox-ttl'] !== undefined || + flags['start-scheduler'] !== undefined || + flags['stop-scheduler'] !== undefined; + + if (!hasAnyUpdateFlag) { + this.error( + t( + 'commands.realm.update.noChanges', + 'No update flags specified. Use --max-sandbox-ttl, --default-sandbox-ttl, --start-scheduler, or --stop-scheduler.', + ), + ); + } + + this.log( + t('commands.realm.update.updating', 'Updating realm {{realm}} on {{host}}...', { + realm, + host, + }), + ); + + const body: RealmConfigurationUpdateRequestModel = {}; + + if (flags['max-sandbox-ttl'] !== undefined || flags['default-sandbox-ttl'] !== undefined) { + body.sandbox = body.sandbox ?? {}; + body.sandbox.sandboxTTL = body.sandbox.sandboxTTL ?? {}; + + if (flags['max-sandbox-ttl'] !== undefined) { + body.sandbox.sandboxTTL.maximum = flags['max-sandbox-ttl']; + } + + if (flags['default-sandbox-ttl'] !== undefined) { + body.sandbox.sandboxTTL.defaultValue = flags['default-sandbox-ttl']; + } + } + + // Helper to parse scheduler flags (JSON or "null") + const parseScheduler = (value: string | undefined) => { + if (!value) return; + if (value === 'null') return null; + + try { + return JSON.parse(value) as OdsComponents['schemas']['WeekdaySchedule']; + } catch { + this.error( + t('commands.realm.update.schedulerParseError', 'Invalid JSON for scheduler. Use valid JSON or "null".'), + ); + } + }; + + const startScheduler = parseScheduler(flags['start-scheduler']); + const stopScheduler = parseScheduler(flags['stop-scheduler']); + + if (startScheduler !== undefined) { + body.sandbox = body.sandbox ?? {}; + // null removes the existing schedule + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body.sandbox.startScheduler = startScheduler as any; + } + + if (stopScheduler !== undefined) { + body.sandbox = body.sandbox ?? {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body.sandbox.stopScheduler = stopScheduler as any; + } + + const result = await this.odsClient.PATCH('/realms/{realm}/configuration', { + params: {path: {realm}}, + body, + }); + + if (result.error) { + this.error( + t('commands.realm.update.error', 'Failed to update realm {{realm}}: {{message}}', { + realm, + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + if (this.jsonEnabled()) { + return result.data as RealmConfigurationResponse | undefined; + } + + this.log( + t('commands.realm.update.success', 'Successfully updated realm {{realm}}.', { + realm, + }), + ); + + return result.data as RealmConfigurationResponse | undefined; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/realm/usage.ts b/packages/b2c-cli/src/commands/sandbox/realm/usage.ts new file mode 100644 index 00000000..822c9c09 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/realm/usage.ts @@ -0,0 +1,167 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +type RealmUsageModel = OdsComponents['schemas']['RealmUsageModel']; + +/** + * Show realm-level usage information. + */ +export default class SandboxRealmUsage extends OdsCommand { + static aliases = ['ods:realm:usage', 'realm:usage']; + + static args = { + realm: Args.string({ + description: 'Realm ID (four-letter ID)', + required: true, + }), + }; + + static description = withDocs( + t('commands.realm.usage.description', 'Show usage information for a realm'), + '/cli/realm.html#b2c-realm-usage', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz', + '<%= config.bin %> <%= command.id %> zzzz --from 2026-02-08 --to 2026-02-11', + '<%= config.bin %> <%= command.id %> zzzz --granularity daily', + '<%= config.bin %> <%= command.id %> zzzz --detailed-report --json', + ]; + + static flags = { + from: Flags.string({ + description: + 'Earliest date to include in usage (ISO 8601, defaults to 30 days in the past if omitted by the API)', + }), + to: Flags.string({ + description: 'Latest date to include in usage (ISO 8601, defaults to today if omitted by the API)', + }), + granularity: Flags.string({ + description: 'Granularity of usage data (daily, weekly, monthly)', + }), + 'detailed-report': Flags.boolean({ + description: 'Include detailed usage information in the response', + default: false, + }), + } as const; + + async run(): Promise { + const {args, flags} = await this.parse(SandboxRealmUsage); + const realm = args.realm; + const host = this.odsHost; + + this.log( + t('commands.realm.usage.fetching', 'Fetching realm usage for {{realm}} from {{host}}...', { + realm, + host, + }), + ); + + const result = await this.odsClient.GET('/realms/{realm}/usage', { + params: { + path: {realm}, + query: { + from: flags.from, + to: flags.to, + detailedReport: flags['detailed-report'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + granularity: flags.granularity as any, + }, + }, + }); + + if (result.error) { + this.error( + t('commands.realm.usage.error', 'Failed to fetch realm usage: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as OdsComponents['schemas']['RealmUsageResponse'] | undefined)?.data; + + if (!data) { + this.log(t('commands.realm.usage.noData', 'No usage data was returned for this realm.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as OdsComponents['schemas']['RealmUsageResponse']; + } + + this.printRealmUsageSummary(data); + return data; + } + + private printRealmUsageSummary(usage: RealmUsageModel): void { + console.log('Realm Usage Summary'); + + console.log('───────────────────'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyUsage = usage as any; + + const metrics: Array<[string, number | undefined]> = [ + ['Active sandboxes', anyUsage.activeSandboxes], + ['Created sandboxes', anyUsage.createdSandboxes], + ['Deleted sandboxes', anyUsage.deletedSandboxes], + ['Minutes up', anyUsage.minutesUp], + ['Minutes down', anyUsage.minutesDown], + ['Sandbox seconds', anyUsage.sandboxSeconds], + ]; + + let hasSummaryMetric = false; + + for (const [label, value] of metrics) { + if (value !== undefined) { + hasSummaryMetric = true; + + console.log(`${label}: ${value}`); + } + } + + if (anyUsage.minutesUpByProfile && anyUsage.minutesUpByProfile.length > 0) { + console.log(); + + console.log('Minutes up by profile:'); + for (const item of anyUsage.minutesUpByProfile) { + if (item.profile && item.minutes !== undefined) { + console.log(` ${item.profile}: ${item.minutes} minutes`); + } + } + } + + const hasDetailedData = + (anyUsage.granularUsage && anyUsage.granularUsage.length > 0) || + (anyUsage.sandboxDetails && anyUsage.sandboxDetails.length > 0); + + if ( + !hasSummaryMetric && + !hasDetailedData && + !(anyUsage.minutesUpByProfile && anyUsage.minutesUpByProfile.length > 0) + ) { + console.log( + t('commands.realm.usage.emptyPeriod', 'No usage data was returned for this realm in the requested period.'), + ); + } else if (hasDetailedData) { + console.log(); + + console.log( + t( + 'commands.realm.usage.detailedHint', + 'Detailed usage data is available; re-run with --json to see full details.', + ), + ); + } + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/reset.ts b/packages/b2c-cli/src/commands/sandbox/reset.ts new file mode 100644 index 00000000..6529e581 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/reset.ts @@ -0,0 +1,132 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, waitForSandbox, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../i18n/index.js'; +import {confirm} from '@inquirer/prompts'; + +type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; + +/** + * Command to reset an on-demand sandbox to clean state. + */ +export default class SandboxReset extends OdsCommand { + static aliases = ['ods:reset']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', + required: true, + }), + }; + + static description = withDocs( + t( + 'commands.sandbox.reset.description', + 'Reset a sandbox to clean state (clears all data and code but preserves configuration)', + ), + '/cli/sandbox.html#b2c-sandbox-reset', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', + '<%= config.bin %> <%= command.id %> zzzv-123', + '<%= config.bin %> <%= command.id %> zzzv-123 --wait', + '<%= config.bin %> <%= command.id %> zzzv-123 --force --json', + ]; + + static flags = { + wait: Flags.boolean({ + char: 'w', + description: 'Wait for the sandbox to reach started state after reset', + default: false, + }), + 'poll-interval': Flags.integer({ + description: 'Polling interval in seconds when using --wait', + default: 10, + dependsOn: ['wait'], + }), + timeout: Flags.integer({ + description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)', + default: 600, + dependsOn: ['wait'], + }), + force: Flags.boolean({ + char: 'f', + description: 'Skip confirmation prompt', + default: false, + }), + }; + + async run(): Promise { + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); + const {wait, 'poll-interval': pollInterval, timeout, force} = this.flags; + + // 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, + }); + + if (!confirmed) { + this.log(t('commands.sandbox.reset.cancelled', 'Reset cancelled')); + this.exit(0); + } + } + + this.log(t('commands.sandbox.reset.resetting', 'Resetting sandbox {{sandboxId}}...', {sandboxId})); + + const result = await this.odsClient.POST('/sandboxes/{sandboxId}/operations', { + params: { + path: {sandboxId}, + }, + body: { + operation: 'reset', + }, + }); + + if (!result.data?.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error(`Failed to reset sandbox: ${message}`); + } + + const operation = result.data.data; + + if (wait) { + await waitForSandbox(this.odsClient, { + sandboxId, + targetState: 'started', + pollIntervalSeconds: pollInterval, + timeoutSeconds: timeout, + onPoll: (info) => { + this.log( + t('commands.sandbox.reset.polling', '[{{elapsed}}s] State: {{state}}', { + state: info.state, + elapsed: info.elapsedSeconds, + }), + ); + }, + }); + } + + if (wait) { + this.log(t('commands.sandbox.reset.completed', 'Sandbox reset completed and is now started')); + } else { + this.log( + t('commands.sandbox.reset.triggered', 'Reset operation {{operationState}}. Sandbox state: {{sandboxState}}', { + operationState: operation.operationState, + sandboxState: operation.sandboxState || 'unknown', + }), + ); + } + + return operation; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/usage.ts b/packages/b2c-cli/src/commands/sandbox/usage.ts new file mode 100644 index 00000000..9edac35d --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/usage.ts @@ -0,0 +1,154 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../i18n/index.js'; + +type SandboxUsageModel = OdsComponents['schemas']['SandboxUsageModel']; + +/** + * Show sandbox-level usage information. + */ +export default class SandboxUsage extends OdsCommand { + static aliases = ['ods:usage']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., zzzz-001)', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.usage.description', 'Show usage information for a specific sandbox'), + '/cli/sandbox.html#b2c-sandbox-usage', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz-001', + '<%= config.bin %> <%= command.id %> zzzz-001 --from 2026-02-08 --to 2026-02-11', + '<%= config.bin %> <%= command.id %> zzzz-001 --from 2026-02-08 --to 2026-02-11 --json', + ]; + + static flags = { + from: Flags.string({ + description: 'Start date for usage data (ISO 8601 format, e.g., 2024-01-01)', + }), + to: Flags.string({ + description: 'End date for usage data (ISO 8601 format, e.g., 2024-12-31)', + }), + } as const; + + async run(): Promise { + const {args, flags} = await this.parse(SandboxUsage); + const rawId = args.sandboxId; + const host = this.odsHost; + + const sandboxId = await this.resolveSandboxId(rawId); + + this.log( + t('commands.sandbox.usage.fetching', 'Fetching sandbox usage for {{sandboxId}} from {{host}}...', { + sandboxId, + host, + }), + ); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/usage', { + params: { + path: {sandboxId}, + query: { + from: flags.from, + to: flags.to, + }, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.usage.error', 'Failed to fetch sandbox usage: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as OdsComponents['schemas']['SandboxUsageResponse'] | undefined)?.data; + + if (!data) { + this.log(t('commands.sandbox.usage.noData', 'No usage data was returned for this sandbox.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as OdsComponents['schemas']['SandboxUsageResponse']; + } + + this.printSandboxUsageSummary(data); + return data; + } + + private printSandboxUsageSummary(usage: SandboxUsageModel): void { + console.log('Sandbox Usage Summary'); + + console.log('─────────────────────'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyUsage = usage as any; + + const metrics: Array<[string, number | undefined]> = [ + ['Sandbox seconds', anyUsage.sandboxSeconds], + ['Minutes up', anyUsage.minutesUp], + ['Minutes down', anyUsage.minutesDown], + ]; + + let hasSummaryMetric = false; + + for (const [label, value] of metrics) { + if (value !== undefined) { + hasSummaryMetric = true; + + console.log(`${label}: ${value}`); + } + } + + if (anyUsage.minutesUpByProfile && anyUsage.minutesUpByProfile.length > 0) { + console.log(); + + console.log('Minutes up by profile:'); + for (const item of anyUsage.minutesUpByProfile) { + if (item.profile && item.minutes !== undefined) { + console.log(` ${item.profile}: ${item.minutes} minutes`); + } + } + } + + const hasDetailedData = + (anyUsage.granularUsage && anyUsage.granularUsage.length > 0) || + (anyUsage.history && anyUsage.history.length > 0); + + if ( + !hasSummaryMetric && + !hasDetailedData && + !(anyUsage.minutesUpByProfile && anyUsage.minutesUpByProfile.length > 0) + ) { + console.log( + t('commands.sandbox.usage.emptyPeriod', 'No usage data was returned for this sandbox in the requested period.'), + ); + } else if (hasDetailedData) { + console.log(); + + console.log( + t( + 'commands.sandbox.usage.detailedHint', + 'Detailed usage data is available; re-run with --json to see full details.', + ), + ); + } + } +} diff --git a/packages/b2c-cli/test/commands/sandbox/alias/create.test.ts b/packages/b2c-cli/test/commands/sandbox/alias/create.test.ts new file mode 100644 index 00000000..8b7647e3 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/alias/create.test.ts @@ -0,0 +1,146 @@ +/* + * 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 SandboxAliasCreate from '../../../../src/commands/sandbox/alias/create.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{POST: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox alias create', () => { + 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(SandboxAliasCreate as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('creates an alias with correct body and returns the alias', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001', hostname: 'my-store.example.com'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async POST(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + id: 'alias-1', + name: 'my-store.example.com', + status: 'PENDING', + }, + }, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/aliases'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(requestOptions).to.have.nested.property('body.name', 'my-store.example.com'); + + expect(result.id).to.equal('alias-1'); + expect(result.name).to.equal('my-store.example.com'); + }); + + it('sets unique and letsencrypt flags in the request body', async () => { + const command = await setupCommand( + {unique: true, letsencrypt: true}, + { + sandboxId: 'zzzz-001', + hostname: 'secure-store.example.com', + }, + ); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestOptions: any; + + stubOdsClient(command, { + async POST(_url: string, options: any) { + requestOptions = options; + return { + data: { + data: { + id: 'alias-2', + name: 'secure-store.example.com', + status: 'PENDING', + unique: true, + requestLetsEncryptCertificate: true, + }, + }, + }; + }, + }); + + await runSilent(() => command.run()); + + expect(requestOptions).to.have.nested.property('body.unique', true); + expect(requestOptions).to.have.nested.property('body.requestLetsEncryptCertificate', true); + }); + + it('throws a helpful error when the create call fails', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001', hostname: 'my-store.example.com'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + stubOdsClient(command, { + async POST() { + return { + data: undefined, + error: {error: {message: 'Something went wrong'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to create alias'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/alias/delete.test.ts b/packages/b2c-cli/test/commands/sandbox/alias/delete.test.ts new file mode 100644 index 00000000..5ebe5931 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/alias/delete.test.ts @@ -0,0 +1,120 @@ +/* + * 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 SandboxAliasDelete from '../../../../src/commands/sandbox/alias/delete.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{DELETE: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox alias delete', () => { + 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(SandboxAliasDelete as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('deletes an alias when --force is provided', async () => { + const command = await setupCommand({force: true}, {sandboxId: 'zzzz-001', aliasId: 'alias-1'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async DELETE(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + response: {status: 204}, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/aliases/{sandboxAliasId}'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(requestOptions).to.have.nested.property('params.path.sandboxAliasId', 'alias-1'); + expect(result.success).to.equal(true); + }); + + it('ignores 404 errors and still reports success', async () => { + const command = await setupCommand({force: true}, {sandboxId: 'zzzz-001', aliasId: 'alias-1'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + stubOdsClient(command, { + async DELETE() { + return { + response: {status: 404}, + error: {error: {message: 'Not Found'}}, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(result.success).to.equal(true); + }); + + it('throws a helpful error when delete fails with non-404', async () => { + const command = await setupCommand({force: true}, {sandboxId: 'zzzz-001', aliasId: 'alias-1'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + stubOdsClient(command, { + async DELETE() { + return { + response: {status: 500, statusText: 'Internal Server Error'}, + error: {error: {message: 'Something went wrong'}}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to delete alias'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/alias/list.test.ts b/packages/b2c-cli/test/commands/sandbox/alias/list.test.ts new file mode 100644 index 00000000..33ac2cc5 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/alias/list.test.ts @@ -0,0 +1,138 @@ +/* + * 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 SandboxAliasList from '../../../../src/commands/sandbox/alias/list.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, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox alias list', () => { + 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(SandboxAliasList as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('lists all aliases when no alias-id is provided', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + + 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: [ + {id: 'alias-1', name: 'a.example.com', status: 'ACTIVE'}, + {id: 'alias-2', name: 'b.example.com', status: 'PENDING'}, + ], + }, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/aliases'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(result).to.be.an('array').with.lengthOf(2); + }); + + it('fetches a specific alias when alias-id is provided', async () => { + const command = await setupCommand({json: true, 'alias-id': 'alias-1'}, {sandboxId: 'zzzz-001'}); + + 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: { + id: 'alias-1', + name: 'a.example.com', + status: 'ACTIVE', + }, + }, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/aliases/{sandboxAliasId}'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(requestOptions).to.have.nested.property('params.path.sandboxAliasId', 'alias-1'); + expect(result.id).to.equal('alias-1'); + }); + + it('throws a helpful error when list call fails', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + stubOdsClient(command, { + async GET() { + return { + data: undefined, + error: {error: {message: 'Something went wrong'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch aliases'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/ips.test.ts b/packages/b2c-cli/test/commands/sandbox/ips.test.ts new file mode 100644 index 00000000..93b2398e --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/ips.test.ts @@ -0,0 +1,182 @@ +/* + * 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 SandboxIps from '../../../src/commands/sandbox/ips.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, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +describe('sandbox ips', () => { + 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(SandboxIps as any, config, flags, {}); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('calls /system when no realm flag is provided', async () => { + const command = await setupCommand({}); + + stubJsonEnabled(command, false); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + inboundIps: ['1.1.1.1'], + outboundIps: ['2.2.2.2'], + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/system'); + expect(requestOptions).to.deep.equal({}); + expect(result).to.deep.equal({inboundIps: ['1.1.1.1'], outboundIps: ['2.2.2.2']}); + }); + + it('calls /realms/{realm}/system when realm flag is provided', async () => { + const command = await setupCommand({realm: 'zzzz'}); + + stubJsonEnabled(command, false); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + inboundIps: ['1.1.1.1'], + outboundIps: ['2.2.2.2'], + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/realms/{realm}/system'); + expect(requestOptions).to.have.nested.property('params.path.realm', 'zzzz'); + expect(result).to.deep.equal({inboundIps: ['1.1.1.1'], outboundIps: ['2.2.2.2']}); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}); + + stubJsonEnabled(command, true); + + const response = { + data: { + inboundIps: ['1.1.1.1'], + outboundIps: ['2.2.2.2'], + }, + } as any; + + stubOdsClient(command, { + async GET() { + return {data: response}; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(result).to.equal(response as any); + }); + + it('logs and returns undefined when no data is returned', async () => { + const command = await setupCommand({}); + + stubJsonEnabled(command, false); + + const logSpy = sinon.spy(command, 'log'); + + stubOdsClient(command, { + async GET() { + return {data: {data: undefined}}; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(result).to.be.undefined; + expect(logSpy.called).to.be.true; + }); + + it('throws a helpful error when the API call fails', async () => { + const command = await setupCommand({}); + + stubJsonEnabled(command, false); + + stubOdsClient(command, { + async GET() { + return { + data: undefined, + error: {error: {message: 'Something went wrong'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch sandbox IP information'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/get.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/get.test.ts new file mode 100644 index 00000000..f6ded44a --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/get.test.ts @@ -0,0 +1,103 @@ +/* + * 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 SandboxRealmGet from '../../../../src/commands/sandbox/realm/get.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, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox realm get', () => { + 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(SandboxRealmGet as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('returns realm and configuration in JSON mode', async () => { + const command = await setupCommand({json: true}, {realm: 'zzzz'}); + + const response = { + data: { + data: { + id: 'zzzz', + name: 'Test Realm', + configuration: { + sandbox: { + totalNumberOfSandboxes: 5, + }, + }, + }, + }, + } as any; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(result.realm.id).to.equal('zzzz'); + expect(result.configuration?.sandbox?.totalNumberOfSandboxes).to.equal(5); + }); + + it('throws a helpful error when the API call fails', async () => { + const command = await setupCommand({}, {realm: 'zzzz'}); + + stubOdsClient(command, { + async GET() { + return { + data: undefined, + error: {error: {message: 'Something went wrong'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch realm zzzz'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts new file mode 100644 index 00000000..7ccbf166 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxRealmList from '../../../../src/commands/sandbox/realm/list.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, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox realm list', () => { + 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(SandboxRealmList as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + 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}`); + }); + + stubOdsClient(command, {GET: getStub}); + + const result: any = await runSilent(() => command.run()); + + 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); + }); + + it('fetches configuration for a 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}`); + }); + + stubOdsClient(command, {GET: getStub}); + + const result: any = await runSilent(() => command.run()); + + expect(result.realms).to.have.lengthOf(1); + expect(result.realms[0].realmId).to.equal('zzzz'); + expect(result.realms[0].configuration?.enabled).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/update.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/update.test.ts new file mode 100644 index 00000000..1ef1596c --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/update.test.ts @@ -0,0 +1,120 @@ +/* + * 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 SandboxRealmUpdate from '../../../../src/commands/sandbox/realm/update.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{PATCH: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox realm update', () => { + 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(SandboxRealmUpdate as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('builds TTL update body correctly', async () => { + const command = await setupCommand({'max-sandbox-ttl': 72, 'default-sandbox-ttl': 24, json: true}, {realm: 'zzzz'}); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async PATCH(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return {data: {}}; + }, + }); + + await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/realms/{realm}/configuration'); + expect(requestOptions).to.have.nested.property('params.path.realm', 'zzzz'); + expect(requestOptions).to.have.nested.property('body.sandbox.sandboxTTL.maximum', 72); + expect(requestOptions).to.have.nested.property('body.sandbox.sandboxTTL.defaultValue', 24); + }); + + it('parses scheduler flags and includes them in the body', async () => { + const startSchedule = {weekdays: ['MONDAY'], time: '08:00:00Z'}; + + const command = await setupCommand( + { + 'start-scheduler': JSON.stringify(startSchedule), + 'stop-scheduler': 'null', + json: true, + }, + {realm: 'zzzz'}, + ); + + let requestOptions: any; + + stubOdsClient(command, { + async PATCH(_url: string, options: any) { + requestOptions = options; + return {data: {}}; + }, + }); + + await runSilent(() => command.run()); + + expect(requestOptions).to.have.nested.property('body.sandbox.startScheduler'); + expect(requestOptions.body.sandbox.startScheduler).to.deep.equal(startSchedule); + expect(requestOptions).to.have.nested.property('body.sandbox.stopScheduler', null); + }); + + it('throws a helpful error on invalid scheduler JSON', async () => { + const command = await setupCommand({'start-scheduler': 'not-json'}, {realm: 'zzzz'}); + + stubOdsClient(command, { + async PATCH() { + return {data: {}}; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Invalid JSON for scheduler'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/usage.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/usage.test.ts new file mode 100644 index 00000000..f080c596 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/usage.test.ts @@ -0,0 +1,160 @@ +/* + * 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 SandboxRealmUsage from '../../../../src/commands/sandbox/realm/usage.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, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox realm usage', () => { + 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(SandboxRealmUsage as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('calls /realms/{realm}/usage with correct query parameters', async () => { + const command = await setupCommand( + { + from: '2024-01-01', + to: '2024-01-31', + granularity: 'daily', + 'detailed-report': true, + }, + {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: { + activeSandboxes: 3, + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/realms/{realm}/usage'); + expect(requestOptions).to.have.nested.property('params.path.realm', 'zzzz'); + expect(requestOptions).to.have.nested.property('params.query.from', '2024-01-01'); + expect(requestOptions).to.have.nested.property('params.query.to', '2024-01-31'); + expect(requestOptions).to.have.nested.property('params.query.granularity', 'daily'); + expect(requestOptions).to.have.nested.property('params.query.detailedReport', true); + + // summary mode returns the inner usage model + expect(result).to.deep.equal({activeSandboxes: 3}); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {realm: 'zzzz'}); + + const response = { + data: { + data: { + activeSandboxes: 5, + }, + }, + }; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(result).to.deep.equal(response.data.data as any); + }); + + it('logs and returns undefined when no data is returned', async () => { + const command = await setupCommand({}, {realm: 'zzzz'}); + + sinon.stub(command as any, 'jsonEnabled').returns(false); + + const logSpy = sinon.spy(command, 'log'); + + stubOdsClient(command, { + async GET() { + return {data: {data: undefined}}; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(result).to.be.undefined; + expect(logSpy.called).to.be.true; + }); + + it('throws a helpful error when the API call fails', async () => { + const command = await setupCommand({}, {realm: 'zzzz'}); + + sinon.stub(command as any, 'jsonEnabled').returns(false); + + stubOdsClient(command, { + async GET() { + return { + data: undefined, + error: {error: {message: 'Something went wrong'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch realm usage'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/reset.test.ts b/packages/b2c-cli/test/commands/sandbox/reset.test.ts new file mode 100644 index 00000000..1355dfa2 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/reset.test.ts @@ -0,0 +1,158 @@ +/* + * 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 SandboxReset from '../../../src/commands/sandbox/reset.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, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +describe('sandbox reset', () => { + 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(SandboxReset as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('triggers reset operation without wait and returns operation', async () => { + const command = await setupCommand({force: true}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubJsonEnabled(command, false); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async POST(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + id: 'op-1', + operationState: 'accepted', + sandboxState: 'resetting', + }, + }, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/operations'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(requestOptions).to.have.nested.property('body.operation', 'reset'); + + expect(result.id).to.equal('op-1'); + expect(result.operationState).to.equal('accepted'); + }); + + it('waits for sandbox to reach started state when --wait is set', async () => { + const command = await setupCommand( + {force: true, wait: true, 'poll-interval': 5, timeout: 60, json: true}, + { + sandboxId: 'zzzz-001', + }, + ); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + stubOdsClient(command, { + async POST() { + return { + data: { + data: { + id: 'op-1', + operationState: 'accepted', + sandboxState: 'resetting', + }, + }, + }; + }, + async GET() { + return { + data: { + data: { + id: 'sb-uuid-123', + realm: 'zzzz', + state: 'started', + }, + }, + response: new Response(), + } as any; + }, + }); + + const result: any = await runSilent(() => command.run()); + + // We verify that the reset operation itself was created; detailed polling + // behavior is covered by the SDK's own tests for waitForSandbox. + expect(result.id).to.equal('op-1'); + }); + + it('throws a helpful error when the reset operation fails', async () => { + const command = await setupCommand({force: true}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + stubOdsClient(command, { + async POST() { + return { + data: undefined, + error: {error: {message: 'Something went wrong'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to reset sandbox'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/usage.test.ts b/packages/b2c-cli/test/commands/sandbox/usage.test.ts new file mode 100644 index 00000000..1e202e29 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/usage.test.ts @@ -0,0 +1,162 @@ +/* + * 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 SandboxUsage from '../../../src/commands/sandbox/usage.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, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox usage', () => { + 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(SandboxUsage as any, config, flags, args); + + // Avoid real config / OAuth behavior + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('calls /sandboxes/{sandboxId}/usage with resolved sandbox id and date range', async () => { + const command = await setupCommand({from: '2024-01-01', to: '2024-01-31'}, {sandboxId: 'zzzz-001'}); + + // Stub JSON mode off so we exercise the summary path + sinon.stub(command as any, 'jsonEnabled').returns(false); + + // Stub resolveSandboxId so we do not depend on its implementation here + const resolveStub = 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: { + sandboxSeconds: 42, + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + + // ensure resolveSandboxId was used + expect(resolveStub.calledOnceWithExactly('zzzz-001')).to.be.true; + + // ensure correct endpoint and query params + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/usage'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(requestOptions).to.have.nested.property('params.query.from', '2024-01-01'); + expect(requestOptions).to.have.nested.property('params.query.to', '2024-01-31'); + + // summary mode returns the inner usage model + expect(result).to.deep.equal({sandboxSeconds: 42}); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const response = { + data: { + data: { + sandboxSeconds: 10, + }, + }, + }; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + + // In JSON mode, the command returns the inner usage model + expect(result).to.deep.equal(response.data.data as any); + }); + + it('logs and returns undefined when no data is returned', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const logSpy = sinon.spy(command, 'log'); + + stubOdsClient(command, { + async GET() { + return {data: {data: undefined}}; + }, + }); + + (command as any).argv = ['zzzz-001']; + + const result = await runSilent(() => command.run()); + + expect(result).to.be.undefined; + expect(logSpy.called).to.be.true; + }); + + it('throws a helpful error when the API call fails', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + stubOdsClient(command, { + async GET() { + return { + data: undefined, + error: {error: {message: 'Something went wrong'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch sandbox usage'); + } + }); +});