diff --git a/.changeset/ods-enhancements.md b/.changeset/ods-enhancements.md new file mode 100644 index 00000000..dbe5f1ab --- /dev/null +++ b/.changeset/ods-enhancements.md @@ -0,0 +1,10 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-dx-docs': patch +--- + +ODS CLI: **`b2c sandbox create`** adds **`--emails`** for notification addresses; **`b2c sandbox update`** adds **`--start-scheduler`**, **`--stop-scheduler`**, **`--clear-start-scheduler`**, and **`--clear-stop-scheduler`**; **`b2c realm update`** adds **`--emails`**, **`--start-scheduler`**, **`--stop-scheduler`**, **`--clear-start-scheduler`**, and **`--clear-stop-scheduler`**. + +Sandbox API: **`b2c sandbox operations list`** and **`b2c sandbox operations get`** (inspect lifecycle operations); **`b2c sandbox alias get`** (get one alias by ID, same endpoint as **`alias list --alias-id`**). + +User guide updated for scheduling flags, sandbox operations, and **`b2c sandbox alias get`**. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 1743af2b..b7c57998 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -12,7 +12,7 @@ These commands were previously available as `b2c ods `. The `ods` prefi ## Sandbox ID Formats -Commands that operate on a specific sandbox (`get`, `update`, `start`, `stop`, `restart`, `delete`) accept two ID formats: +Commands that operate on a specific sandbox (`get`, `update`, `start`, `stop`, `restart`, `delete`, `operations list`, `operations get`) accept two ID formats: | Format | Example | Description | |--------|---------|-------------| @@ -149,6 +149,7 @@ b2c sandbox create --realm | `--ttl` | Time to live in hours (0 for infinite) | `24` | | `--profile` | Resource profile (medium, large, xlarge, xxlarge) | `medium` | | `--auto-scheduled` | Enable automatic start/stop scheduling | `false` | +| `--emails` | Comma-separated list of notification email addresses | | | `--wait`, `-w` | Wait for sandbox to reach started or failed state | `false` | | `--poll-interval` | Polling interval in seconds when using --wait | `10` | | `--timeout` | Maximum wait time in seconds (0 for no timeout) | `600` | @@ -177,6 +178,9 @@ b2c sandbox create --realm abcd --wait # Create with auto-scheduling enabled b2c sandbox create --realm abcd --auto-scheduled +# Create with notification emails +b2c sandbox create --realm abcd --emails dev@example.com,ops@example.com + # Create without automatic permissions b2c sandbox create --realm abcd --no-set-permissions @@ -388,6 +392,106 @@ b2c sandbox restart zzzv_123 --json --- +## b2c sandbox operations list {#b2c-sandbox-operations-list} + +List past and current **operations** on a sandbox (for example start, stop, restart, reset, create, delete, upgrade). This maps to the ODS API `GET /sandboxes/{sandboxId}/operations` endpoint. + +To **request** a lifecycle operation (`start`, `stop`, `restart`, `reset`), use `b2c sandbox start|stop|restart|reset` instead; those commands call `POST /sandboxes/{sandboxId}/operations`. + +### Usage + +```bash +b2c sandbox operations list +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +### Flags + +| Flag | Short | Description | Default | +|------|-------|-------------|---------| +| `--from` | | Earliest operation time (ISO 8601). If omitted, the API defaults to roughly the last 30 days. | | +| `--to` | | Latest operation time (ISO 8601). If omitted, the API defaults to now. | | +| `--operation-state` | | Filter by lifecycle state: `pending`, `running`, or `finished` | | +| `--status` | | Filter finished operations by outcome: `success` or `failure` | | +| `--operation` | | Filter by operation type: `start`, `stop`, `restart`, `reset`, `create`, `delete`, `upgrade` | | +| `--sort-order` | | Sort order: `asc` or `desc` | | +| `--sort-by` | | Sort field: `created`, `operation_state`, `status`, or `operation` | | +| `--page` | | Page index (0-based) | | +| `--per-page` | | Page size (API default is typically 20) | | +| `--columns`, `-c` | | Columns to display (comma-separated); see **Available columns** below | | +| `--extended`, `-x` | | Include extended columns (for example `operationBy`) | `false` | + +### Available columns + +`id`, `operation`, `operationState`, `status`, `sandboxState`, `createdAt`, `operationBy` (extended) + +**Default columns:** `operation`, `operationState`, `status`, `sandboxState`, `createdAt`, `id` + +### Examples + +```bash +# List recent operations for a sandbox +b2c sandbox operations list zzzv-123 + +# Only finished operations +b2c sandbox operations list zzzv-123 --operation-state finished + +# Date range and paging +b2c sandbox operations list zzzv-123 --from 2025-01-01 --to 2025-12-31 --page 0 --per-page 50 + +# Custom columns +b2c sandbox operations list zzzv-123 --columns operation,operationState,status,createdAt + +# Full API response (includes paging metadata when present) +b2c sandbox operations list zzzv-123 --json +``` + +### Output + +When not using `--json`, the command prints a one-line paging summary when metadata is present, then a table of operations. Use `--json` to inspect `metadata` (page, totals) and the raw `data` array. + +--- + +## b2c sandbox operations get {#b2c-sandbox-operations-get} + +Return details for a **single** sandbox operation by its operation UUID (maps to `GET /sandboxes/{sandboxId}/operations/{operationId}`). Use the operation `id` from `b2c sandbox operations list` or from the JSON output of `b2c sandbox start|stop|restart|reset` when using `--json`. + +### Usage + +```bash +b2c sandbox operations get +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | +| `OPERATIONID` | Operation UUID | Yes | + +### Examples + +```bash +# Show operation details (human-readable) +b2c sandbox operations get zzzv-123 550e8400-e29b-41d4-a716-446655440000 + +# Operation as JSON (sandbox operation model only) +b2c sandbox operations get zzzv-123 550e8400-e29b-41d4-a716-446655440000 --json +``` + +### Output + +When not using `--json`, the command prints a short **Operation Details** block including operation type, state, outcome, sandbox state, created time, and who ran the operation when available. + +With `--json`, the command returns the **operation object** (not the full API envelope), consistent with `b2c sandbox get`. + +--- + ## b2c sandbox delete Delete an on-demand sandbox. @@ -513,6 +617,8 @@ b2c sandbox update [FLAGS] | `--resource-profile` | Resource profile (`medium`, `large`, `xlarge`, `xxlarge`) | | `--tags` | Comma-separated list of tags | | `--emails` | Comma-separated list of notification email addresses | +| `--start-scheduler` | Start schedule JSON (or `"null"` to remove existing scheduler) | +| `--stop-scheduler` | Stop schedule JSON (or `"null"` to remove existing scheduler) | At least one flag is required. @@ -540,6 +646,9 @@ b2c sandbox update zzzv-123 --resource-profile large # Set notification emails b2c sandbox update zzzv-123 --emails dev@example.com,qa@example.com +# Enable automatic scheduling and set scheduler values +b2c sandbox update zzzv-123 --auto-scheduled --start-scheduler '{"weekdays":["MONDAY"],"time":"08:00:00Z"}' --stop-scheduler "null" + # Combine multiple updates b2c sandbox update zzzv-123 --ttl 48 --resource-profile xlarge --tags ci,nightly @@ -551,6 +660,8 @@ b2c sandbox update zzzv-123 --ttl 48 --json - The `--ttl` value is added to the existing sandbox lifetime, not an absolute value. Together with previous extensions, it must adhere to the realm's maximum TTL configuration. - Setting `--ttl` to 0 or less gives the sandbox an infinite lifetime (subject to realm configuration). +- `--auto-scheduled` controls whether automatic start/stop behavior is enabled for the sandbox. +- `--start-scheduler` and `--stop-scheduler` define scheduler values, but scheduler automation is effective only when `--auto-scheduled` is enabled. --- @@ -685,6 +796,7 @@ Alias commands are available both under the `sandbox` topic and the legacy `ods` - `b2c sandbox alias create` - `b2c sandbox alias list` +- `b2c sandbox alias get` - `b2c sandbox alias delete` ### b2c sandbox alias create @@ -779,6 +891,39 @@ When listing multiple aliases without `--json`, the command prints a table with: - Whether the alias is unique - DNS verification record (if any) +For **one alias** by ID, you can also use **`b2c sandbox alias get`** (same API as `list --alias-id`). + +### b2c sandbox alias get {#b2c-sandbox-alias-get} + +Get details for a **single** hostname alias (ODS API `GET /sandboxes/{sandboxId}/aliases/{sandboxAliasId}`). This is equivalent to `b2c sandbox alias list --alias-id ` but uses positional arguments only. + +#### Usage + +```bash +b2c sandbox alias get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | +| `ALIASID` | Alias UUID | Yes | + +#### Examples + +```bash +# Human-readable details +b2c sandbox alias get zzzv-123 some-alias-uuid + +# Alias object as JSON (same shape as list/get for a single alias) +b2c sandbox alias get zzzv-123 some-alias-uuid --json +``` + +#### Output + +When not using `--json`, the command prints an **Alias Details** section (hostname, status, uniqueness, DNS TXT verification, registration URL when present, optional cookie hint). With `--json`, it returns the **alias object** only. + ### b2c sandbox alias delete Delete a sandbox alias. @@ -1174,6 +1319,8 @@ b2c sandbox realm update [FLAGS] | `--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) | +| `--emails` | Comma-separated list of realm notification email addresses | +| `--local-users-allowed` / `--no-local-users-allowed` | Enable or disable local users in realm sandbox configuration | The scheduler flags expect a JSON value or the literal string `"null"`: @@ -1195,6 +1342,9 @@ b2c sandbox realm update zzzz \ # Remove an existing stop scheduler b2c sandbox realm update zzzz --stop-scheduler "null" + +# Update realm emails and local user setting +b2c sandbox realm update zzzz --emails dev@example.com,ops@example.com --local-users-allowed ``` If no update flags are provided, the command fails with a helpful error explaining which flags can be used. diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 583cc936..78fccbee 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -179,6 +179,9 @@ }, "realm": { "description": "Manage sandbox realms (alias for 'sandbox realm')" + }, + "operations": { + "description": "List and inspect sandbox lifecycle operations (alias for 'sandbox operations')" } } }, diff --git a/packages/b2c-cli/src/commands/sandbox/alias/get.ts b/packages/b2c-cli/src/commands/sandbox/alias/get.ts new file mode 100644 index 00000000..1cf72838 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/alias/get.ts @@ -0,0 +1,118 @@ +/* + * 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 SandboxAliasModel = OdsComponents['schemas']['SandboxAliasModel']; + +/** + * Get details for a single sandbox hostname alias (ODS API GET /sandboxes/{sandboxId}/aliases/{sandboxAliasId}). + */ +export default class SandboxAliasGet extends OdsCommand { + static aliases = ['ods:alias:get']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', + required: true, + }), + aliasId: Args.string({ + description: 'Alias UUID', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.alias.get.description', 'Show details for a specific sandbox hostname alias'), + '/cli/sandbox.html#b2c-sandbox-alias-get', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzv-123 alias-uuid-here', + '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 alias-uuid-here --json', + ]; + + async run(): Promise { + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); + const {aliasId} = this.args; + + this.log( + t('commands.sandbox.alias.get.fetching', 'Fetching alias {{aliasId}} for sandbox {{sandboxId}}...', { + aliasId, + sandboxId: this.args.sandboxId, + }), + ); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/aliases/{sandboxAliasId}', { + params: { + path: {sandboxId, sandboxAliasId: aliasId}, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.alias.get.error', 'Failed to fetch alias: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const alias = result.data?.data; + if (!alias) { + this.log(t('commands.sandbox.alias.get.noData', 'No alias details were returned.')); + return undefined; + } + + if (this.jsonEnabled()) { + return alias; + } + + this.printAlias(alias); + return alias; + } + + private printAlias(alias: SandboxAliasModel): void { + const ui = cliui({width: process.stdout.columns || 80}); + ui.div({text: 'Alias Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const labelWidth = 22; + const rows: [string, string | undefined][] = [ + ['ID', alias.id], + ['Hostname', alias.name], + ['Status', alias.status], + ['Unique', alias.unique === undefined ? undefined : String(alias.unique)], + ['Sandbox ID', alias.sandboxId], + [ + 'Let’s Encrypt', + alias.requestLetsEncryptCertificate === undefined ? undefined : String(alias.requestLetsEncryptCertificate), + ], + ['DNS verification (TXT)', alias.domainVerificationRecord], + ['Registration URL', alias.registration], + ]; + + for (const [label, value] of rows) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: labelWidth, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + + if (alias.cookie) { + const c = alias.cookie; + ui.div( + {text: 'Cookie:', width: labelWidth, padding: [0, 2, 0, 0]}, + {text: `${c.name}=${c.value}${c.path ? ` (path: ${c.path})` : ''}`, padding: [0, 0, 0, 0]}, + ); + } + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/create.ts b/packages/b2c-cli/src/commands/sandbox/create.ts index 8e2ae74c..8fa5d8f3 100644 --- a/packages/b2c-cli/src/commands/sandbox/create.ts +++ b/packages/b2c-cli/src/commands/sandbox/create.ts @@ -62,6 +62,7 @@ export default class SandboxCreate extends OdsCommand { '<%= config.bin %> <%= command.id %> --realm abcd', '<%= config.bin %> <%= command.id %> --realm abcd --ttl 48', '<%= config.bin %> <%= command.id %> --realm abcd --profile large', + '<%= config.bin %> <%= command.id %> --realm abcd --emails dev@example.com,ops@example.com', '<%= config.bin %> <%= command.id %> --realm abcd --auto-scheduled', '<%= config.bin %> <%= command.id %> --realm abcd --wait', '<%= config.bin %> <%= command.id %> --realm abcd --wait --poll-interval 15', @@ -87,6 +88,9 @@ export default class SandboxCreate extends OdsCommand { description: 'Enable automatic start/stop scheduling', default: false, }), + emails: Flags.string({ + description: 'Comma-separated list of notification email addresses', + }), wait: Flags.boolean({ char: 'w', description: 'Wait for the sandbox to reach started or failed state before returning', @@ -131,6 +135,7 @@ export default class SandboxCreate extends OdsCommand { const profile = this.flags.profile as SandboxResourceProfile; const ttl = this.flags.ttl; const autoScheduled = this.flags['auto-scheduled']; + const emails = this.flags.emails; const wait = this.flags.wait; const pollInterval = this.flags['poll-interval']; const timeout = this.flags.timeout; @@ -171,6 +176,7 @@ export default class SandboxCreate extends OdsCommand { const result = await this.odsClient.POST('/sandboxes', { body: { realm, + emails: emails ? emails.split(',').map((email) => email.trim()) : undefined, ttl, resourceProfile: profile, autoScheduled, diff --git a/packages/b2c-cli/src/commands/sandbox/operations/get.ts b/packages/b2c-cli/src/commands/sandbox/operations/get.ts new file mode 100644 index 00000000..f28fd39b --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/operations/get.ts @@ -0,0 +1,105 @@ +/* + * 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 SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; + +/** + * Show details for a single sandbox operation (ODS API GET /sandboxes/{id}/operations/{operationId}). + */ +export default class SandboxOperationsGet extends OdsCommand { + static aliases = ['ods:operations:get']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., zzzz-001)', + required: true, + }), + operationId: Args.string({ + description: 'Operation UUID returned when the operation was started', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.operations.get.description', 'Show details for a specific sandbox operation by ID'), + '/cli/sandbox.html#b2c-sandbox-operations-get', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz-001 550e8400-e29b-41d4-a716-446655440000', + '<%= config.bin %> <%= command.id %> zzzz-001 550e8400-e29b-41d4-a716-446655440000 --json', + ]; + + async run(): Promise { + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); + const {operationId} = this.args; + + this.log( + t('commands.sandbox.operations.get.fetching', 'Fetching operation {{operationId}} for sandbox {{sandboxId}}...', { + operationId, + sandboxId: this.args.sandboxId, + }), + ); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/operations/{operationId}', { + params: { + path: {sandboxId, operationId}, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.operations.get.error', 'Failed to fetch sandbox operation: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const payload = result.data; + const op = payload?.data; + if (!op) { + this.log(t('commands.sandbox.operations.get.noData', 'No operation details were returned.')); + return undefined; + } + + if (this.jsonEnabled()) { + return op; + } + + this.printOperation(op); + return op; + } + + private printOperation(op: SandboxOperationModel): void { + const ui = cliui({width: process.stdout.columns || 80}); + ui.div({text: 'Operation Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['Operation ID', op.id], + ['Type', op.operation], + ['Op state', op.operationState], + ['Outcome', op.status], + ['Sandbox state', op.sandboxState], + ['Created', op.createdAt ? new Date(op.createdAt).toLocaleString() : undefined], + ['By', op.operationBy], + ]; + const labelWidth = 20; + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: labelWidth, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/operations/list.ts b/packages/b2c-cli/src/commands/sandbox/operations/list.ts new file mode 100644 index 00000000..880fc656 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/operations/list.ts @@ -0,0 +1,221 @@ +/* + * 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, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; +type SandboxOperationListResponse = OdsComponents['schemas']['SandboxOperationListResponse']; + +export const OPERATION_COLUMNS: Record> = { + id: { + header: 'Operation ID', + get: (o) => o.id || '-', + }, + operation: { + header: 'Operation', + get: (o) => o.operation || '-', + }, + operationState: { + header: 'Op state', + get: (o) => o.operationState || '-', + }, + status: { + header: 'Status', + get: (o) => o.status || '-', + }, + sandboxState: { + header: 'Sandbox', + get: (o) => o.sandboxState || '-', + }, + createdAt: { + header: 'Created', + get: (o) => (o.createdAt ? new Date(o.createdAt).toISOString() : '-'), + }, + operationBy: { + header: 'By', + get: (o) => o.operationBy || '-', + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['operation', 'operationState', 'status', 'sandboxState', 'createdAt', 'id']; + +const tableRenderer = new TableRenderer(OPERATION_COLUMNS); + +/** + * List past and current operations for a sandbox (ODS API GET /sandboxes/{id}/operations). + */ +export default class SandboxOperationsList extends OdsCommand { + static aliases = ['ods:operations:list']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., zzzz-001)', + required: true, + }), + }; + + static description = withDocs( + t( + 'commands.sandbox.operations.list.description', + 'List operations performed on a sandbox (start, stop, restart, reset, etc.)', + ), + '/cli/sandbox.html#b2c-sandbox-operations-list', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz-001', + '<%= config.bin %> <%= command.id %> zzzz-001 --operation-state finished', + '<%= config.bin %> <%= command.id %> zzzz-001 --from 2025-01-01 --to 2025-12-31', + '<%= config.bin %> <%= command.id %> zzzz-001 --page 0 --per-page 50', + '<%= config.bin %> <%= command.id %> zzzz-001 --json', + ]; + + static flags = { + from: Flags.string({ + description: 'Earliest operation time (ISO 8601); default is about 30 days ago', + }), + to: Flags.string({ + description: 'Latest operation time (ISO 8601); default is now', + }), + 'operation-state': Flags.string({ + description: 'Filter by operation lifecycle state', + options: ['pending', 'running', 'finished'], + }), + status: Flags.string({ + description: 'Filter by outcome (finished operations)', + options: ['success', 'failure'], + }), + operation: Flags.string({ + description: 'Filter by operation type', + options: ['start', 'stop', 'restart', 'reset', 'create', 'delete', 'upgrade'], + }), + 'sort-order': Flags.string({ + description: 'Sort order for results', + options: ['asc', 'desc'], + }), + 'sort-by': Flags.string({ + description: 'Field to sort by', + options: ['created', 'operation_state', 'status', 'operation'], + }), + page: Flags.integer({ + description: 'Page index (0-based)', + min: 0, + }), + 'per-page': Flags.integer({ + description: 'Page size (default from API is 20)', + min: 1, + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(OPERATION_COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Include extended columns (e.g. operationBy)', + default: false, + }), + }; + + async run(): Promise { + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); + + this.log( + t('commands.sandbox.operations.list.fetching', 'Fetching operations for sandbox {{sandboxId}}...', { + sandboxId: this.args.sandboxId, + }), + ); + + const q = this.buildQuery(); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/operations', { + params: { + path: {sandboxId}, + query: q, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.operations.list.error', 'Failed to list sandbox operations: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const payload = result.data; + if (!payload) { + this.log(t('commands.sandbox.operations.list.empty', 'No operation data returned.')); + return undefined; + } + + if (this.jsonEnabled()) { + return payload; + } + + const operations = payload.data ?? []; + if (operations.length === 0) { + this.log(t('commands.sandbox.operations.list.none', 'No operations found for this sandbox.')); + return payload; + } + + const meta = payload.metadata; + if (meta && (meta.page !== undefined || meta.pageCount !== undefined || meta.totalCount !== undefined)) { + const parts: string[] = []; + if (meta.page !== undefined) parts.push(`page ${meta.page}`); + if (meta.perPage !== undefined) parts.push(`${meta.perPage} per page`); + if (meta.pageCount !== undefined) parts.push(`${meta.pageCount} page(s)`); + if (meta.totalCount !== undefined) parts.push(`${meta.totalCount} total`); + if (parts.length > 0) { + this.log(parts.join(', ')); + } + } + + tableRenderer.render(operations, this.getSelectedColumns()); + + return payload; + } + + private buildQuery(): Record { + const f = this.flags; + const q: Record = {}; + if (f.from) q.from = f.from; + if (f.to) q.to = f.to; + if (f['operation-state']) q.operation_state = f['operation-state']; + if (f.status) q.status = f.status; + if (f.operation) q.operation = f.operation; + if (f['sort-order']) q.sort_order = f['sort-order']; + if (f['sort-by']) q.sort_by = f['sort-by']; + if (f.page !== undefined) q.page = f.page; + if (f['per-page'] !== undefined) q.per_page = f['per-page']; + return q; + } + + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + return tableRenderer.getColumnKeys(); + } + + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/realm/update.ts b/packages/b2c-cli/src/commands/sandbox/realm/update.ts index e7ba6e8c..d22a26af 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/update.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/update.ts @@ -8,6 +8,7 @@ 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 {parseSchedulerFlag} from '../../../utils/ods/scheduler.js'; type RealmConfigurationUpdateRequestModel = OdsComponents['schemas']['RealmConfigurationUpdateRequestModel']; type RealmConfigurationResponse = OdsComponents['schemas']['RealmConfigurationResponse']; @@ -35,8 +36,9 @@ export default class SandboxRealmUpdate extends OdsCommand <%= command.id %> zzzz --max-sandbox-ttl 72', '<%= config.bin %> <%= command.id %> zzzz --default-sandbox-ttl 24', + '<%= config.bin %> <%= command.id %> zzzz --emails dev@example.com,ops@example.com', '<%= config.bin %> <%= command.id %> zzzz --start-scheduler \'{"weekdays":["MONDAY"],"time":"08:00:00Z"}\'', - '<%= config.bin %> <%= command.id %> zzzz --stop-scheduler "null"', + '<%= config.bin %> <%= command.id %> zzzz --clear-stop-scheduler', ]; static flags = { @@ -47,12 +49,28 @@ export default class SandboxRealmUpdate extends OdsCommand { - 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".'), - ); - } - }; + if (flags.emails !== undefined) { + body.emails = flags.emails.split(',').map((email) => email.trim()); + } - const startScheduler = parseScheduler(flags['start-scheduler']); - const stopScheduler = parseScheduler(flags['stop-scheduler']); + if (flags['local-users-allowed'] !== undefined) { + body.sandbox = body.sandbox ?? {}; + // Not in RealmSandboxConfigurationUpdateModel in the published ODS spec; API accepts it on PATCH. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (body.sandbox as any).localUsersAllowed = flags['local-users-allowed']; + } + + // Parse scheduler flags using shared utility + let startScheduler: null | OdsComponents['schemas']['WeekdaySchedule'] | undefined; + let stopScheduler: null | OdsComponents['schemas']['WeekdaySchedule'] | undefined; + + try { + startScheduler = parseSchedulerFlag(flags['start-scheduler'], flags['clear-start-scheduler']); + stopScheduler = parseSchedulerFlag(flags['stop-scheduler'], flags['clear-stop-scheduler']); + } catch { + this.error(t('commands.realm.update.schedulerParseError', 'Invalid JSON for scheduler.')); + } if (startScheduler !== undefined) { body.sandbox = body.sandbox ?? {}; diff --git a/packages/b2c-cli/src/commands/sandbox/update.ts b/packages/b2c-cli/src/commands/sandbox/update.ts index bb48f95c..9b1fb3d8 100644 --- a/packages/b2c-cli/src/commands/sandbox/update.ts +++ b/packages/b2c-cli/src/commands/sandbox/update.ts @@ -8,6 +8,7 @@ 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'; +import {parseSchedulerFlag} from '../../utils/ods/scheduler.js'; type SandboxModel = OdsComponents['schemas']['SandboxModel']; type SandboxUpdateRequestModel = OdsComponents['schemas']['SandboxUpdateRequestModel']; @@ -44,6 +45,8 @@ export default class SandboxUpdate extends OdsCommand { '<%= config.bin %> <%= command.id %> zzzv-123 --resource-profile large', '<%= config.bin %> <%= command.id %> zzzv-123 --tags tag1,tag2', '<%= config.bin %> <%= command.id %> zzzv-123 --emails user@example.com,dev@example.com', + '<%= config.bin %> <%= command.id %> zzzv-123 --start-scheduler \'{"weekdays":["MONDAY"],"time":"08:00:00Z"}\'', + '<%= config.bin %> <%= command.id %> zzzv-123 --clear-stop-scheduler', '<%= config.bin %> <%= command.id %> zzzv-123 --ttl 48 --resource-profile xlarge --tags ci,nightly --json', ]; @@ -65,11 +68,37 @@ export default class SandboxUpdate extends OdsCommand { emails: Flags.string({ description: 'Comma-separated list of notification email addresses', }), + 'start-scheduler': Flags.string({ + description: 'Start schedule JSON. Format: {"weekdays":[...],"time":"..."}', + exclusive: ['clear-start-scheduler'], + }), + 'clear-start-scheduler': Flags.boolean({ + description: 'Remove existing start scheduler', + exclusive: ['start-scheduler'], + }), + 'stop-scheduler': Flags.string({ + description: 'Stop schedule JSON. Format: {"weekdays":[...],"time":"..."}', + exclusive: ['clear-stop-scheduler'], + }), + 'clear-stop-scheduler': Flags.boolean({ + description: 'Remove existing stop scheduler', + exclusive: ['stop-scheduler'], + }), }; async run(): Promise { const sandboxId = await this.resolveSandboxId(this.args.sandboxId); - const {ttl, 'auto-scheduled': autoScheduled, 'resource-profile': resourceProfile, tags, emails} = this.flags; + const { + ttl, + 'auto-scheduled': autoScheduled, + 'resource-profile': resourceProfile, + tags, + emails, + 'start-scheduler': startSchedulerRaw, + 'clear-start-scheduler': clearStartScheduler, + 'stop-scheduler': stopSchedulerRaw, + 'clear-stop-scheduler': clearStopScheduler, + } = this.flags; // Require at least one update flag if ( @@ -77,10 +106,17 @@ export default class SandboxUpdate extends OdsCommand { autoScheduled === undefined && resourceProfile === undefined && tags === undefined && - emails === undefined + emails === undefined && + startSchedulerRaw === undefined && + clearStartScheduler === undefined && + stopSchedulerRaw === undefined && + clearStopScheduler === undefined ) { this.error( - 'At least one update flag is required. Use --ttl, --auto-scheduled, --resource-profile, --tags, or --emails.', + t( + 'commands.sandbox.update.no_flags', + 'At least one update flag is required. Use --ttl, --auto-scheduled, --resource-profile, --tags, --emails, --start-scheduler, --clear-start-scheduler, --stop-scheduler, or --clear-stop-scheduler.', + ), ); } @@ -106,6 +142,20 @@ export default class SandboxUpdate extends OdsCommand { body.emails = emails.split(',').map((email) => email.trim()); } + try { + const startScheduler = parseSchedulerFlag(startSchedulerRaw, clearStartScheduler); + if (startScheduler !== undefined) { + body.startScheduler = startScheduler as unknown as SandboxUpdateRequestModel['startScheduler']; + } + + const stopScheduler = parseSchedulerFlag(stopSchedulerRaw, clearStopScheduler); + if (stopScheduler !== undefined) { + body.stopScheduler = stopScheduler as unknown as SandboxUpdateRequestModel['stopScheduler']; + } + } catch { + this.error(t('commands.sandbox.update.invalid_json', 'Invalid JSON for scheduler flag.')); + } + this.log(t('commands.sandbox.update.updating', 'Updating sandbox {{sandboxId}}...', {sandboxId})); const result = await this.odsClient.PATCH('/sandboxes/{sandboxId}', { @@ -117,7 +167,7 @@ export default class SandboxUpdate extends OdsCommand { if (!result.data?.data) { const message = getApiErrorMessage(result.error, result.response); - this.error(`Failed to update sandbox: ${message}`); + this.error(t('commands.sandbox.update.error', 'Failed to update sandbox: {{message}}', {message})); } const sandbox = result.data.data; diff --git a/packages/b2c-cli/src/utils/ods/scheduler.ts b/packages/b2c-cli/src/utils/ods/scheduler.ts new file mode 100644 index 00000000..1e2c5a45 --- /dev/null +++ b/packages/b2c-cli/src/utils/ods/scheduler.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; + +type WeekdaySchedule = OdsComponents['schemas']['WeekdaySchedule']; + +/** + * Parse scheduler flag from CLI input. + * @param value - JSON string for scheduler + * @param clear - Boolean flag to clear scheduler + * @returns null if clear is true, undefined if value is undefined, or parsed WeekdaySchedule + */ +export function parseSchedulerFlag( + value: string | undefined, + clear: boolean | undefined, +): null | undefined | WeekdaySchedule { + if (clear) { + return null; + } + + if (value === undefined) { + return undefined; + } + + return JSON.parse(value) as WeekdaySchedule; +} diff --git a/packages/b2c-cli/test/commands/sandbox/alias/get.test.ts b/packages/b2c-cli/test/commands/sandbox/alias/get.test.ts new file mode 100644 index 00000000..10bc5ac2 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/alias/get.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxAliasGet from '../../../../src/commands/sandbox/alias/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, + }); +} + +describe('sandbox alias 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(SandboxAliasGet as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls GET /sandboxes/{sandboxId}/aliases/{sandboxAliasId}', async () => { + const aliasUuid = '550e8400-e29b-41d4-a716-446655440000'; + const command = await setupCommand({}, {sandboxId: 'zzzz-001', aliasId: aliasUuid}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + status: 'Success', + data: { + id: aliasUuid, + name: 'www.example.com', + status: 'verified', + }, + }, + }; + }, + }); + + const result = 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', aliasUuid); + expect(result).to.deep.include({id: aliasUuid, name: 'www.example.com'}); + }); + + it('returns alias model in JSON mode', async () => { + const aliasUuid = '550e8400-e29b-41d4-a716-446655440000'; + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001', aliasId: aliasUuid}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const alias = {id: aliasUuid, name: 'store.example.com', status: 'pending' as const}; + + stubOdsClient(command, { + async GET() { + return { + data: { + status: 'Success' as const, + data: alias, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(alias); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/create.test.ts b/packages/b2c-cli/test/commands/sandbox/create.test.ts index b5e355e3..4b6521af 100644 --- a/packages/b2c-cli/test/commands/sandbox/create.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/create.test.ts @@ -381,6 +381,32 @@ describe('sandbox create', () => { expect(requestBody.stopScheduler).to.deep.equal(stopSchedule); }); + it('should include emails in POST body', async () => { + const command = setupCreateCommand(); + + (command as any).flags = { + realm: 'abcd', + ttl: 24, + profile: 'medium', + wait: false, + 'set-permissions': false, + emails: 'dev@example.com,ops@example.com', + }; + + let requestBody: any; + stubOdsClient(command, { + async POST(_url: string, options: any) { + requestBody = options.body; + return { + data: {data: {id: 'sb-1', state: 'creating'}}, + }; + }, + }); + + await runSilent(() => command.run()); + expect(requestBody.emails).to.deep.equal(['dev@example.com', 'ops@example.com']); + }); + it('should throw on invalid JSON for scheduler flags', async () => { const command = setupCreateCommand(); diff --git a/packages/b2c-cli/test/commands/sandbox/operations/get.test.ts b/packages/b2c-cli/test/commands/sandbox/operations/get.test.ts new file mode 100644 index 00000000..84f72699 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/operations/get.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxOperationsGet from '../../../../src/commands/sandbox/operations/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, + }); +} + +describe('sandbox operations 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(SandboxOperationsGet as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls GET /sandboxes/{sandboxId}/operations/{operationId}', async () => { + const opId = '550e8400-e29b-41d4-a716-446655440000'; + const command = await setupCommand({}, {sandboxId: 'zzzz-001', operationId: opId}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + status: 'Success', + data: { + id: opId, + operation: 'start', + operationState: 'finished', + status: 'success', + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/operations/{operationId}'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(requestOptions).to.have.nested.property('params.path.operationId', opId); + expect(result).to.include({id: opId, operation: 'start'}); + }); + + it('returns operation model in JSON mode', async () => { + const opId = '550e8400-e29b-41d4-a716-446655440000'; + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001', operationId: opId}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const operation = {id: opId, operation: 'stop' as const, operationState: 'finished' as const}; + + stubOdsClient(command, { + async GET() { + return { + data: { + status: 'Success' as const, + data: operation, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(operation); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/operations/list.test.ts b/packages/b2c-cli/test/commands/sandbox/operations/list.test.ts new file mode 100644 index 00000000..af31b5df --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/operations/list.test.ts @@ -0,0 +1,125 @@ +/* + * 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 SandboxOperationsList from '../../../../src/commands/sandbox/operations/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, + }); +} + +describe('sandbox operations 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(SandboxOperationsList as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls GET /sandboxes/{sandboxId}/operations with resolved sandbox id', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + status: 'Success', + data: [ + { + id: 'op-1', + operation: 'start', + operationState: 'finished', + status: 'success', + }, + ], + metadata: {page: 0, totalCount: 1}, + }, + }; + }, + }); + + const result = (await runSilent(() => command.run())) as {data?: {length: number}}; + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/operations'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(result).to.have.property('data'); + expect(result.data).to.have.length(1); + }); + + it('passes query filters when provided', async () => { + const command = await setupCommand( + {'operation-state': 'finished', page: 1, 'per-page': 10}, + {sandboxId: 'zzzz-001'}, + ); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestOptions: any; + + stubOdsClient(command, { + async GET(_url: string, options: any) { + requestOptions = options; + return {data: {status: 'Success', data: []}}; + }, + }); + + await runSilent(() => command.run()); + expect(requestOptions.params.query).to.deep.include({ + operation_state: 'finished', + page: 1, + per_page: 10, + }); + }); + + it('returns full API payload in JSON mode', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const apiResponse = { + status: 'Success' as const, + data: [], + metadata: {page: 0}, + }; + + stubOdsClient(command, { + async GET() { + return {data: apiResponse}; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(apiResponse); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/update.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/update.test.ts index 1ef1596c..cc0ae459 100644 --- a/packages/b2c-cli/test/commands/sandbox/realm/update.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/realm/update.test.ts @@ -101,6 +101,29 @@ describe('sandbox realm update', () => { expect(requestOptions).to.have.nested.property('body.sandbox.stopScheduler', null); }); + it('includes emails and local-users-allowed in body', async () => { + const command = await setupCommand( + { + emails: 'dev@example.com,ops@example.com', + 'local-users-allowed': true, + 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.body.emails).to.deep.equal(['dev@example.com', 'ops@example.com']); + expect(requestOptions).to.have.nested.property('body.sandbox.localUsersAllowed', true); + }); + it('throws a helpful error on invalid scheduler JSON', async () => { const command = await setupCommand({'start-scheduler': 'not-json'}, {realm: 'zzzz'}); diff --git a/packages/b2c-cli/test/commands/sandbox/update.test.ts b/packages/b2c-cli/test/commands/sandbox/update.test.ts index a03f4b83..7bc3bc4d 100644 --- a/packages/b2c-cli/test/commands/sandbox/update.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/update.test.ts @@ -193,6 +193,39 @@ describe('sandbox update', () => { expect(result.emails).to.deep.equal(['dev@example.com', 'qa@example.com']); }); + it('parses scheduler flags and includes them in PATCH body', async () => { + const command = await setupCommand( + { + 'start-scheduler': '{"weekdays":["MONDAY"],"time":"08:00:00Z"}', + 'stop-scheduler': 'null', + }, + {sandboxId: 'zzzz-001'}, + ); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubJsonEnabled(command, true); + + let requestOptions: any; + stubOdsClient(command, { + async PATCH(_: string, options: any) { + requestOptions = options; + return { + data: { + data: { + id: 'sb-uuid-123', + realm: 'zzzz', + state: 'started', + }, + }, + }; + }, + }); + + await runSilent(() => command.run()); + expect(requestOptions).to.have.nested.property('body.startScheduler.time', '08:00:00Z'); + expect(requestOptions).to.have.nested.property('body.stopScheduler', null); + }); + it('requires at least one update flag including --resource-profile', async () => { const command = await setupCommand({}, {sandboxId: 'zzzz-001'});