diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 55524994..07a426c9 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -92,7 +92,7 @@ b2c sandbox list ### Available Columns -`realm`, `instance`, `state`, `profile`, `created`, `eol`, `id`, `hostname`, `createdBy`, `autoScheduled` +`realm`, `instance`, `state`, `profile`, `created`, `eol`, `id`, `hostname`, `createdBy`, `autoScheduled`, `isCloned` ### Examples @@ -119,14 +119,16 @@ b2c sandbox list --json ### Output ``` -Realm Instance State Profile Created EOL -────────────────────────────────────────────────────────────────────────── -abcd 001 started medium 2024-12-20 2024-12-21 -abcd 002 stopped large 2024-12-19 2024-12-20 22:30 +Realm Instance State Profile Created EOL Cloned +───────────────────────────────────────────────────────────────────────── +abcd 001 started medium 2024-12-20 2024-12-21 No +abcd 002 stopped large 2024-12-19 2024-12-20 22:30 Yes ``` The `EOL` column displays `YYYY-MM-DD` normally. When a sandbox expires within 24 hours (or is already expired), the time is also shown as `YYYY-MM-DD HH:mm` (UTC). +The `isCloned` column indicates whether a sandbox was created by cloning another sandbox (`Yes`) or not (`No`). + --- ## b2c sandbox create @@ -216,6 +218,12 @@ b2c sandbox get |----------|-------------|----------| | `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--clone-details` | Include detailed clone information if the sandbox was created by cloning | `false` | + ### Examples ```bash @@ -225,6 +233,9 @@ b2c sandbox get abc12345-1234-1234-1234-abc123456789 # Get sandbox details using realm-instance format b2c sandbox get zzzv-123 +# Get sandbox details with clone information +b2c sandbox get zzzv-123 --clone-details + # Output as JSON b2c sandbox get zzzv_123 --json ``` @@ -239,6 +250,19 @@ Displays detailed information about the sandbox including: - Creation time and end-of-life - Links to BM and storefront +If the sandbox was created by cloning another sandbox, a "Clone Details" section is displayed showing: +- Cloned From (realm-instance identifier) +- Source Instance ID (UUID) + +When the `--clone-details` flag is used, additional clone metadata is included: +- Clone ID +- Status +- Target Profile +- Progress Percentage +- Elapsed Time +- Custom Code Version +- Storefront Count + --- ## b2c sandbox info @@ -647,6 +671,225 @@ b2c sandbox alias delete zzzv-123 alias-uuid-here --json --- +## Sandbox Cloning + +On-demand sandbox cloning enables you to create replicas of existing sandboxes in minutes, not hours. It helps teams move faster while reducing risk by providing fully isolated environments for development, testing, and operational workflows. + +With a single API call, you can provision a fully isolated replica of your sandbox that includes your database, application code, platform configurations, and all configured feature toggles. + +**Important:** To ensure a consistent and reliable clone, the source sandbox is automatically placed in a protected **Stopped** state during the cloning process. This safeguard guarantees data integrity and configuration consistency. Once cloning is complete, the source sandbox resumes normal operation. + +Each cloned sandbox is fully isolated, with dedicated compute, storage, and database resources. + +Clone commands are available both under the `sandbox` topic and the legacy `ods` aliases: + +- `b2c sandbox clone list` (`b2c ods clone:list`) +- `b2c sandbox clone create` (`b2c ods clone:create`) +- `b2c sandbox clone get` (`b2c ods clone:get`) + +### Clone ID Format + +Clone IDs follow a specific pattern: `realm-instance-timestamp` + +- Example: `aaaa-002-1642780893121` +- Pattern: 4-letter realm code, followed by 3-digit instance number, followed by 13-digit timestamp + +### b2c sandbox clone list + +List all clones for a specific sandbox. + +#### Usage + +```bash +b2c sandbox clone list +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--from` | Filter clones created on or after this date (ISO 8601 date format, e.g., `2024-01-01`) | +| `--to` | Filter clones created on or before this date (ISO 8601 date format, e.g., `2024-12-31`) | +| `--status` | Filter clones by status (`Pending`, `InProgress`, `Failed`, `Completed`) | +| `--columns`, `-c` | Columns to display (comma-separated) | +| `--extended`, `-x` | Show all columns | + +#### Available Columns + +`cloneId`, `sourceInstance`, `targetInstance`, `status`, `progressPercentage`, `createdAt`, `lastUpdated`, `elapsedTimeInSec`, `customCodeVersion` + +**Default columns:** `cloneId`, `sourceInstance`, `targetInstance`, `status`, `progressPercentage`, `createdAt` + +#### Examples + +```bash +# List all clones for a sandbox +b2c sandbox clone list zzzv-123 + +# Filter by status +b2c sandbox clone list zzzv-123 --status Completed + +# Filter by date range +b2c sandbox clone list zzzv-123 --from 2024-01-01 --to 2024-12-31 + +# Show all columns +b2c sandbox clone list zzzv-123 --extended + +# Custom columns +b2c sandbox clone list zzzv-123 --columns cloneId,status,progressPercentage + +# Output as JSON +b2c sandbox clone list zzzv-123 --json +``` + +#### Output + +``` +Clone ID Source Instance Target Instance Status Progress % Created At +────────────────────────────────────────────────────────────────────────────────────────────── +aaaa-001-1642780893121 aaaa-000 aaaa-001 COMPLETED 100% 2024-02-27 10:00 +aaaa-002-1642780893122 aaaa-000 aaaa-002 IN_PROGRESS 75% 2024-02-27 +``` + +The `Created At` column displays `YYYY-MM-DD HH:mm` when the clone was created within the last 24 hours, otherwise just `YYYY-MM-DD` (all times in UTC). + +### b2c sandbox clone create + +Create a new sandbox clone from an existing sandbox. This creates a complete copy of the source sandbox including all data, configuration, and custom code. + +#### Usage + +```bash +b2c sandbox clone create +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) to clone from | Yes | + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--target-profile` | Resource profile for the cloned sandbox (`medium`, `large`, `xlarge`, `xxlarge`). Optional. | Source sandbox profile | +| `--ttl` | Time to live in hours (0 or negative = infinite, minimum 24 hours). Values between 1-23 are not allowed. | `24` | +| `--emails` | Comma-separated list of notification email addresses | | + +#### Examples + +```bash +# Create a clone with same profile as source sandbox +b2c sandbox clone create zzzv-123 + +# Create a clone with custom TTL (uses source profile) +b2c sandbox clone create zzzv-123 --ttl 48 + +# Create a clone with a different profile +b2c sandbox clone create zzzv-123 --target-profile large + +# Create a clone with large profile and extended TTL +b2c sandbox clone create zzzv-123 --target-profile large --ttl 48 + +# Create a clone with notification emails +b2c sandbox clone create zzzv-123 --emails dev@example.com,qa@example.com + +# Create a clone with infinite TTL +b2c sandbox clone create zzzv-123 --ttl 0 + +# Output as JSON +b2c sandbox clone create zzzv-123 --json +``` + +#### Output + +``` +✓ Sandbox clone creation started successfully +Clone ID: aaaa-002-1642780893121 + +To check the clone status, run: + b2c sandbox clone get zzzv-123 aaaa-002-1642780893121 +``` + +#### Notes + +- **Source sandbox will be stopped:** The source sandbox is automatically placed in a **Stopped** state during cloning to ensure data integrity and configuration consistency. It resumes normal operation once cloning is complete. +- Cloning typically completes in minutes, though duration depends on sandbox size and data volume +- The cloned sandbox is fully isolated with dedicated compute, storage, and database resources +- When `--target-profile` is not specified, the API automatically uses the source sandbox's resource profile (no additional API call is made) +- The TTL must be 0 or negative (infinite), or 24 hours or greater. Values between 1-23 are rejected +- The clone will be created as a new sandbox instance in the same realm + +### b2c sandbox clone get + +Retrieve detailed information about a specific sandbox clone, including status, progress, and metadata. + +#### Usage + +```bash +b2c sandbox clone get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | +| `CLONEID` | Clone ID (e.g., `aaaa-002-1642780893121`) | Yes | + +#### Examples + +```bash +# Get clone details +b2c sandbox clone get zzzv-123 aaaa-002-1642780893121 + +# Output as JSON +b2c sandbox clone get zzzv-123 aaaa-002-1642780893121 --json +``` + +#### Output + +Displays comprehensive clone information in a formatted table: + +``` +Clone Details +────────────────────────────────────────────────── +Clone ID: aaaa-002-1642780893121 +Source Instance: aaaa-000 +Source Instance ID: 11111111-2222-3333-4444-555555555555 +Target Instance: aaaa-002 +Target Instance ID: 66666666-7777-8888-9999-000000000000 +Realm: aaaa +Status: IN_PROGRESS +Progress: 75% +Created At: 2/27/2025, 10:00:00 AM +Last Known State: Finalizing Clone +Custom Code Version: version1 +Storefront Count: 0 +Filesystem Usage Size: 1073741824 +Database Transfer Size: 2147483648 +``` + +For the complete response including all metadata, use the `--json` flag. + +#### Clone Status Values + +| Status | Description | +|--------|-------------| +| `PENDING` | Clone is queued and waiting to start | +| `IN_PROGRESS` | Clone operation is currently running | +| `COMPLETED` | Clone finished successfully | +| `FAILED` | Clone operation failed | + +--- + ## 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: @@ -662,12 +905,12 @@ To run `b2c realm` commands, your user or API client must have **realm‑level a ### b2c realm list -List realms eligible for sandbox management, optionally including a simple usage summary. +List realms eligible for sandbox management. #### Usage ```bash -b2c realm list [REALM] [--show-usage] +b2c realm list [REALM] ``` #### Arguments diff --git a/packages/b2c-cli/src/commands/sandbox/clone/create.ts b/packages/b2c-cli/src/commands/sandbox/clone/create.ts new file mode 100644 index 00000000..60f9643e --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/clone/create.ts @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags, Errors} from '@oclif/core'; +import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to create a sandbox clone. + */ +export default class CloneCreate extends OdsCommand { + static aliases = ['ods:clone:create']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or friendly format like realm-instance) to clone from', + required: true, + }), + }; + + static description = t('commands.clone.create.description', 'Create a new sandbox clone from an existing sandbox'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ', + '<%= config.bin %> <%= command.id %> --target-profile large', + '<%= config.bin %> <%= command.id %> --ttl 48', + '<%= config.bin %> <%= command.id %> --target-profile large --ttl 48 --emails dev@example.com,qa@example.com', + ]; + + static flags = { + 'target-profile': Flags.string({ + description: 'Resource profile for the cloned sandbox (defaults to source sandbox profile)', + required: false, + options: ['medium', 'large', 'xlarge', 'xxlarge'], + }), + emails: Flags.string({ + description: 'Comma-separated list of notification email addresses', + required: false, + multiple: true, + }), + ttl: Flags.integer({ + description: + 'Time to live in hours (0 or negative = infinite, minimum 24 hours). Values between 1-23 are not allowed.', + required: false, + default: 24, + }), + }; + + async run(): Promise<{cloneId?: string}> { + const {sandboxId: rawSandboxId} = this.args; + const {'target-profile': targetProfile, emails, ttl} = this.flags; + + // Validate TTL + if (ttl > 0 && ttl < 24) { + throw new Errors.CLIError( + t( + 'commands.clone.create.invalidTTL', + 'TTL must be 0 or negative (infinite), or 24 hours or greater. Values between 1-23 are not allowed. Received: {{ttl}}', + {ttl}, + ), + ); + } + + // Resolve sandbox ID (handles both UUID and friendly format) + const sandboxId = await this.resolveSandboxId(rawSandboxId); + + this.log(t('commands.clone.create.creating', 'Creating sandbox clone...')); + + // Prepare request body + const requestBody: { + targetProfile?: 'large' | 'medium' | 'xlarge' | 'xxlarge'; + emails?: string[]; + ttl: number; + } = { + ttl, + }; + + // Only include targetProfile if explicitly provided + if (targetProfile) { + requestBody.targetProfile = targetProfile as 'large' | 'medium' | 'xlarge' | 'xxlarge'; + } + + if (emails && emails.length > 0) { + requestBody.emails = emails.flatMap((email) => email.split(',').map((e) => e.trim())); + } + + const result = await this.odsClient.POST('/sandboxes/{sandboxId}/clones', { + params: { + path: {sandboxId}, + }, + body: requestBody, + }); + + if (!result.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error(t('commands.clone.create.error', 'Failed to create sandbox clone: {{message}}', {message})); + } + + const cloneId = result.data.data?.cloneId; + + if (this.jsonEnabled()) { + return {cloneId}; + } + + this.log(t('commands.clone.create.success', '✓ Sandbox clone creation started successfully')); + this.log(t('commands.clone.create.cloneId', 'Clone ID: {{cloneId}}', {cloneId})); + this.log( + t( + 'commands.clone.create.checkStatus', + '\nTo check the clone status, run:\n <%= config.bin %> ods clone get {{sandboxId}} {{cloneId}}', + {sandboxId, cloneId}, + ), + ); + + return {cloneId}; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/clone/get.ts b/packages/b2c-cli/src/commands/sandbox/clone/get.ts new file mode 100644 index 00000000..27df1ead --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/clone/get.ts @@ -0,0 +1,104 @@ +/* + * 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} from '../../../i18n/index.js'; + +type SandboxCloneGetModel = OdsComponents['schemas']['SandboxCloneGetModel']; + +/** + * Command to get details of a specific sandbox clone. + */ +export default class CloneGet extends OdsCommand { + static aliases = ['ods:clone:get']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or friendly format like realm-instance)', + required: true, + }), + cloneId: Args.string({ + description: 'Clone ID in format realm-instance-timestamp (e.g., aaaa-002-1642780893121)', + required: true, + }), + }; + + static description = t('commands.clone.get.description', 'Get detailed information about a specific sandbox clone'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ', + '<%= config.bin %> <%= command.id %> abcd-123 aaaa-002-1642780893121', + ]; + + async run(): Promise<{data?: SandboxCloneGetModel}> { + const {sandboxId: rawSandboxId, cloneId} = this.args; + + // Resolve sandbox ID (handles both UUID and friendly format) + const sandboxId = await this.resolveSandboxId(rawSandboxId); + + this.log(t('commands.clone.get.fetching', 'Fetching clone details...')); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/clones/{cloneId}', { + params: { + path: {sandboxId, cloneId}, + }, + }); + + if (!result.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error(t('commands.clone.get.error', 'Failed to get clone details: {{message}}', {message})); + } + + const clone = result.data.data; + + if (this.jsonEnabled()) { + return {data: clone}; + } + + this.printCloneDetails(clone); + + return {data: clone}; + } + + private buildCloneFields(clone: SandboxCloneGetModel | undefined): [string, string | undefined][] { + return [ + ['Clone ID', clone?.cloneId], + ['Source Instance', clone?.sourceInstance], + ['Source Instance ID', clone?.sourceInstanceId], + ['Target Instance', clone?.targetInstance], + ['Target Instance ID', clone?.targetInstanceId], + ['Realm', clone?.realm], + ['Status', clone?.status], + ['Progress', clone?.progressPercentage === undefined ? '-' : `${clone.progressPercentage}%`], + ['Created At', clone?.createdAt ? new Date(clone.createdAt).toLocaleString() : undefined], + ['Custom Code Version', clone?.customCodeVersion], + ['Storefront Count', clone?.storefrontCount?.toString()], + ['Filesystem Usage Size', clone?.filesystemUsageSize?.toString()], + ['Database Transfer Size', clone?.databaseTransferSize?.toString()], + ]; + } + + private printCloneDetails(clone: SandboxCloneGetModel | undefined): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'Clone Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields = this.buildCloneFields(clone); + + 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]}); + } + } + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/clone/list.ts b/packages/b2c-cli/src/commands/sandbox/clone/list.ts new file mode 100644 index 00000000..9f6697a5 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/clone/list.ts @@ -0,0 +1,170 @@ +/* + * 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} from '../../../i18n/index.js'; + +type SandboxCloneGetModel = OdsComponents['schemas']['SandboxCloneGetModel']; + +export const COLUMNS: Record> = { + cloneId: { + header: 'Clone ID', + get: (c) => c.cloneId || '-', + }, + sourceInstance: { + header: 'Source Instance', + get: (c) => c.sourceInstance || '-', + }, + targetInstance: { + header: 'Target Instance', + get: (c) => c.targetInstance || '-', + }, + status: { + header: 'Status', + get: (c) => c.status || '-', + }, + progressPercentage: { + header: 'Progress %', + get: (c) => (c.progressPercentage === undefined ? '-' : `${c.progressPercentage}%`), + }, + createdAt: { + header: 'Created At', + get(c) { + if (!c.createdAt) return '-'; + const d = new Date(c.createdAt); + const date = d.toISOString().slice(0, 10); + const msSinceCreated = Date.now() - d.getTime(); + if (msSinceCreated <= 24 * 60 * 60 * 1000) { + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + return `${date} ${hh}:${mm}`; + } + return date; + }, + }, + lastUpdated: { + header: 'Last Updated', + get: (c) => (c.lastUpdated ? new Date(c.lastUpdated).toLocaleString() : '-'), + }, + elapsedTimeInSec: { + header: 'Elapsed Time (sec)', + get: (c) => (c.elapsedTimeInSec === undefined ? '-' : c.elapsedTimeInSec.toString()), + }, + customCodeVersion: { + header: 'Custom Code Version', + get: (c) => c.customCodeVersion || '-', + }, +}; + +const DEFAULT_COLUMNS = ['cloneId', 'sourceInstance', 'targetInstance', 'status', 'progressPercentage', 'createdAt']; + +/** + * Command to list sandbox clones for a specific sandbox. + */ +export default class CloneList extends OdsCommand { + static aliases = ['ods:clone:list']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or friendly format like realm-instance)', + required: true, + }), + }; + + static description = t('commands.clone.list.description', 'List all clones for a specific sandbox'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ', + '<%= config.bin %> <%= command.id %> --status COMPLETED', + '<%= config.bin %> <%= command.id %> --from 2024-01-01 --to 2024-12-31', + '<%= config.bin %> <%= command.id %> --extended', + ]; + + static flags = { + from: Flags.string({ + description: 'Filter clones created on or after this date (ISO 8601 date format, e.g., 2024-01-01)', + required: false, + }), + to: Flags.string({ + description: 'Filter clones created on or before this date (ISO 8601 date format, e.g., 2024-12-31)', + required: false, + }), + status: Flags.string({ + description: 'Filter clones by status', + required: false, + options: ['Pending', 'InProgress', 'Failed', 'Completed'], + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns', + default: false, + }), + }; + + async run(): Promise<{data?: SandboxCloneGetModel[]}> { + const {sandboxId: rawSandboxId} = this.args; + const {from: fromDate, to: toDate, status} = this.flags; + + // Resolve sandbox ID (handles both UUID and friendly format) + const sandboxId = await this.resolveSandboxId(rawSandboxId); + + this.log(t('commands.clone.list.fetching', 'Fetching sandbox clones...')); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/clones', { + params: { + path: {sandboxId}, + query: { + fromDate, + toDate, + status: status as 'Completed' | 'Failed' | 'InProgress' | 'Pending' | undefined, + }, + }, + }); + + if (!result.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error(t('commands.clone.list.error', 'Failed to list sandbox clones: {{message}}', {message})); + } + + if (this.jsonEnabled()) { + return {data: result.data.data || []}; + } + + const clones = result.data.data || []; + if (clones.length === 0) { + this.log(t('commands.clone.list.noClones', 'No clones found for this sandbox.')); + return {data: clones}; + } + + const columns = this.getSelectedColumns(); + const tableRenderer = new TableRenderer(COLUMNS); + tableRenderer.render(clones, columns); + + return {data: clones}; + } + + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + return columnsFlag.split(',').map((c) => c.trim()); + } + + if (extended) { + return Object.keys(COLUMNS); + } + + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/get.ts b/packages/b2c-cli/src/commands/sandbox/get.ts index e8d695aa..da8738f1 100644 --- a/packages/b2c-cli/src/commands/sandbox/get.ts +++ b/packages/b2c-cli/src/commands/sandbox/get.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import {Args, ux} from '@oclif/core'; +import {Args, Flags, ux} from '@oclif/core'; import cliui from 'cliui'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; @@ -35,18 +35,30 @@ export default class SandboxGet extends OdsCommand { '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', '<%= config.bin %> <%= command.id %> zzzv-123', '<%= config.bin %> <%= command.id %> zzzv_123 --json', + '<%= config.bin %> <%= command.id %> zzzv_123 --clone-details', ]; + static flags = { + 'clone-details': Flags.boolean({ + description: 'Include detailed clone information if the sandbox was created by cloning', + default: false, + }), + }; + async run(): Promise { const sandboxId = await this.resolveSandboxId(this.args.sandboxId); this.log(t('commands.sandbox.get.fetching', 'Fetching sandbox {{sandboxId}}...', {sandboxId})); - const result = await this.odsClient.GET('/sandboxes/{sandboxId}', { - params: { - path: {sandboxId}, - }, - }); + const params: {path: {sandboxId: string}; query?: {expand: 'clonedetails'[]}} = { + path: {sandboxId}, + }; + + if (this.flags['clone-details']) { + params.query = {expand: ['clonedetails']}; + } + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}', {params}); if (!result.data?.data) { this.error( @@ -67,13 +79,31 @@ export default class SandboxGet extends OdsCommand { return sandbox; } - private printSandboxDetails(sandbox: SandboxModel): void { - const ui = cliui({width: process.stdout.columns || 80}); + private buildCloneFields(sandbox: SandboxModel): [string, string | undefined][] { + const cloneFields: [string, string | undefined][] = [ + ['Cloned From', sandbox.clonedFrom], + ['Source Instance ID', sandbox.sourceInstanceIdentifier], + ]; - ui.div({text: 'Sandbox Details', padding: [1, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + if (sandbox.cloneDetails) { + const details = sandbox.cloneDetails; + cloneFields.push( + ['Clone ID', details.cloneId], + ['Status', details.status], + ['Target Profile', details.targetProfile], + ['Created At', details.createdAt ? new Date(details.createdAt).toLocaleString() : undefined], + ['Progress', details.progressPercentage ? `${details.progressPercentage}%` : undefined], + ['Elapsed Time (sec)', details.elapsedTimeInSec?.toString()], + ['Custom Code Version', details.customCodeVersion], + ['Storefront Count', details.storefrontCount?.toString()], + ); + } + + return cloneFields; + } - const fields: [string, string | undefined][] = [ + private buildSandboxFields(sandbox: SandboxModel): [string, string | undefined][] { + return [ ['ID', sandbox.id], ['Realm', sandbox.realm], ['Instance', sandbox.instance], @@ -88,47 +118,74 @@ export default class SandboxGet extends OdsCommand { ['App Version', sandbox.versions?.app], ['Web Version', sandbox.versions?.web], ]; + } + + private printCloneDetailsSection(ui: ReturnType, sandbox: SandboxModel): void { + if (!sandbox.clonedFrom && !sandbox.sourceInstanceIdentifier && !sandbox.cloneDetails) return; + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Clone Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const cloneFields = this.buildCloneFields(sandbox); + this.printFieldsSection(ui, cloneFields, 25); + } + + private printFieldsSection( + ui: ReturnType, + fields: [string, string | undefined][], + width: number, + ): void { for (const [label, value] of fields) { if (value !== undefined) { - ui.div({text: `${label}:`, width: 20, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + ui.div({text: `${label}:`, width, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); } } + } - // Tags + private printLinksSection(ui: ReturnType, sandbox: SandboxModel): void { + if (!sandbox.links) return; + + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Links', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const links: [string, string | undefined][] = [ + ['Business Manager', sandbox.links.bm], + ['OCAPI', sandbox.links.ocapi], + ['Impex', sandbox.links.impex], + ['Code', sandbox.links.code], + ['Logs', sandbox.links.logs], + ]; + + this.printFieldsSection(ui, links, 20); + } + + private printSandboxDetails(sandbox: SandboxModel): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'Sandbox Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields = this.buildSandboxFields(sandbox); + this.printFieldsSection(ui, fields, 20); + this.printTagsAndEmails(ui, sandbox); + this.printCloneDetailsSection(ui, sandbox); + this.printLinksSection(ui, sandbox); + + ux.stdout(ui.toString()); + } + + private printTagsAndEmails(ui: ReturnType, sandbox: SandboxModel): void { if (sandbox.tags && sandbox.tags.length > 0) { ui.div({text: 'Tags:', width: 20, padding: [0, 2, 0, 0]}, {text: sandbox.tags.join(', '), padding: [0, 0, 0, 0]}); } - // Emails if (sandbox.emails && sandbox.emails.length > 0) { ui.div( {text: 'Emails:', width: 20, padding: [0, 2, 0, 0]}, {text: sandbox.emails.join(', '), padding: [0, 0, 0, 0]}, ); } - - // Links - if (sandbox.links) { - ui.div({text: '', padding: [0, 0, 0, 0]}); - ui.div({text: 'Links', padding: [1, 0, 0, 0]}); - ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); - - const links: [string, string | undefined][] = [ - ['Business Manager', sandbox.links.bm], - ['OCAPI', sandbox.links.ocapi], - ['Impex', sandbox.links.impex], - ['Code', sandbox.links.code], - ['Logs', sandbox.links.logs], - ]; - - for (const [label, value] of links) { - if (value) { - ui.div({text: `${label}:`, width: 20, 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/list.ts b/packages/b2c-cli/src/commands/sandbox/list.ts index 4a2de9ef..99d6f99d 100644 --- a/packages/b2c-cli/src/commands/sandbox/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/list.ts @@ -73,10 +73,14 @@ export const COLUMNS: Record> = { get: (s) => (s.autoScheduled ? 'Yes' : 'No'), extended: true, }, + isCloned: { + header: 'Is Cloned', + get: (s) => (s.clonedFrom ? 'Yes' : 'No'), + }, }; /** Default columns shown without --extended */ -const DEFAULT_COLUMNS = ['realm', 'instance', 'state', 'profile', 'created', 'eol', 'id']; +const DEFAULT_COLUMNS = ['realm', 'instance', 'state', 'profile', 'created', 'eol', 'id', 'isCloned']; const tableRenderer = new TableRenderer(COLUMNS); diff --git a/packages/b2c-cli/test/commands/sandbox/clone/create.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/create.test.ts new file mode 100644 index 00000000..eaff56e5 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/clone/create.test.ts @@ -0,0 +1,338 @@ +/* + * 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 {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import CloneCreate from '../../../../src/commands/sandbox/clone/create.js'; +import {runSilent} from '../../../helpers/test-setup.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClientPost(command: any, handler: (path: string, options?: any) => Promise): void { + Object.defineProperty(command, 'odsClient', { + value: { + POST: handler, + GET: async () => ({data: null, response: new Response()}), + }, + configurable: true, + }); +} + +function stubResolveSandboxId(command: any, handler: (id: string) => Promise): void { + command.resolveSandboxId = handler; +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} + +describe('sandbox clone create', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(CloneCreate.description).to.be.a('string'); + expect(CloneCreate.description).to.include('clone'); + }); + + it('should enable JSON flag', () => { + expect(CloneCreate.enableJsonFlag).to.be.true; + }); + + it('should have sandboxId argument', () => { + expect(CloneCreate.args).to.have.property('sandboxId'); + expect(CloneCreate.args.sandboxId.required).to.be.true; + }); + + it('should have target-profile flag (optional)', () => { + expect(CloneCreate.flags).to.have.property('target-profile'); + expect(CloneCreate.flags['target-profile'].required).to.be.false; + expect(CloneCreate.flags['target-profile'].options).to.deep.equal(['medium', 'large', 'xlarge', 'xxlarge']); + }); + + it('should have optional ttl flag with default', () => { + expect(CloneCreate.flags).to.have.property('ttl'); + expect(CloneCreate.flags.ttl.default).to.equal(24); + }); + }); + + describe('TTL validation', () => { + it('should reject TTL between 1-23', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {'target-profile': 'medium', ttl: 12}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubResolveSandboxId(command, async (id) => id); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/TTL must be 0 or negative.*or 24 hours or greater/); + } + }); + + it('should accept TTL of 0 (infinite)', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {'target-profile': 'medium', ttl: 0}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + let capturedBody: any; + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); + + await command.run(); + + expect(capturedBody.ttl).to.equal(0); + }); + + it('should accept TTL of 24 or greater', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {'target-profile': 'medium', ttl: 48}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + let capturedBody: any; + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); + + await command.run(); + + expect(capturedBody.ttl).to.equal(48); + }); + }); + + describe('target profile defaulting', () => { + it('should not include targetProfile when not specified', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {ttl: 24}; // No target-profile provided + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + let capturedBody: any; + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); + + await command.run(); + + // API will use source profile by default, so we should not include targetProfile + expect(capturedBody.targetProfile).to.be.undefined; + expect(capturedBody.ttl).to.equal(24); + }); + + it('should use explicit target-profile when provided', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {'target-profile': 'medium', ttl: 24}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + let capturedBody: any; + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); + + await command.run(); + + expect(capturedBody.targetProfile).to.equal('medium'); + }); + }); + + describe('output formatting', () => { + it('should return clone ID in JSON mode', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {'target-profile': 'large', ttl: 24}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const mockCloneId = 'aaaa-001-1642780893121'; + + stubOdsClientPost(command, async (path: string, options?: any) => { + expect(path).to.equal('/sandboxes/{sandboxId}/clones'); + expect(options?.params?.path?.sandboxId).to.equal('test-sandbox-id'); + expect(options?.body?.targetProfile).to.equal('large'); + return { + data: {data: {cloneId: mockCloneId}}, + response: new Response(), + }; + }); + + const result = await command.run(); + + expect(result).to.have.property('cloneId'); + expect(result.cloneId).to.equal(mockCloneId); + }); + + it('should display formatted output in non-JSON mode', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {'target-profile': 'medium', ttl: 24}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) logs.push(msg); + }; + + const mockCloneId = 'aaaa-001-1642780893121'; + + stubOdsClientPost(command, async () => { + return { + data: {data: {cloneId: mockCloneId}}, + response: new Response(), + }; + }); + + await runSilent(() => command.run()); + + const combinedLogs = logs.join('\n'); + expect(combinedLogs).to.include('Clone ID'); + expect(combinedLogs).to.include(mockCloneId); + expect(combinedLogs).to.include('started successfully'); + }); + + it('should pass emails to API when provided', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = { + 'target-profile': 'medium', + ttl: 24, + emails: ['dev@example.com', 'qa@example.com'], + }; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + let capturedBody: any; + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); + + await command.run(); + + expect(capturedBody.emails).to.deep.equal(['dev@example.com', 'qa@example.com']); + }); + + it('should handle comma-separated emails in single flag', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = { + 'target-profile': 'medium', + ttl: 24, + emails: ['dev@example.com,qa@example.com'], + }; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + let capturedBody: any; + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); + + await command.run(); + + expect(capturedBody.emails).to.deep.equal(['dev@example.com', 'qa@example.com']); + }); + }); + + describe('error handling', () => { + it('should throw error when API call fails', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {'target-profile': 'medium', ttl: 24}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubResolveSandboxId(command, async (id) => id); + + stubOdsClientPost(command, async () => { + return {data: null, error: {message: 'API Error'}, response: new Response()}; + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Failed to create sandbox clone'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts new file mode 100644 index 00000000..499de422 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts @@ -0,0 +1,321 @@ +/* + * 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 {ux} from '@oclif/core'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import CloneGet from '../../../../src/commands/sandbox/clone/get.js'; +import {runSilent} from '../../../helpers/test-setup.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClientGet(command: any, handler: (path: string, options?: any) => Promise): void { + Object.defineProperty(command, 'odsClient', { + value: { + GET: handler, + }, + configurable: true, + }); +} + +function stubResolveSandboxId(command: any, handler: (id: string) => Promise): void { + command.resolveSandboxId = handler; +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} + +describe('sandbox clone get', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(CloneGet.description).to.be.a('string'); + expect(CloneGet.description).to.include('clone'); + }); + + it('should enable JSON flag', () => { + expect(CloneGet.enableJsonFlag).to.be.true; + }); + + it('should have sandboxId argument', () => { + expect(CloneGet.args).to.have.property('sandboxId'); + expect(CloneGet.args.sandboxId.required).to.be.true; + }); + + it('should have cloneId argument', () => { + expect(CloneGet.args).to.have.property('cloneId'); + expect(CloneGet.args.cloneId.required).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return clone details in JSON mode', async () => { + const command = new CloneGet(['test-sandbox-id', 'aaaa-001-1642780893121'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id', cloneId: 'aaaa-001-1642780893121'}; + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const mockClone = { + cloneId: 'aaaa-001-1642780893121', + status: 'COMPLETED', + realm: 'aaaa', + sourceInstance: 'aaaa-000', + sourceInstanceId: '11111111-2222-3333-4444-555555555555', + targetInstance: 'aaaa-001', + targetInstanceId: '66666666-7777-8888-9999-000000000000', + targetProfile: 'large', + progressPercentage: 100, + elapsedTimeInSec: 3600, + createdAt: '2025-02-27T10:00:00Z', + createdBy: 'test@example.com', + lastUpdated: '2025-02-27T11:00:00Z', + customCodeVersion: '1.0.0', + storefrontCount: 5, + filesystemUsageSize: 1_073_741_824, // 1 GB + databaseTransferSize: 2_147_483_648, // 2 GB + }; + + stubOdsClientGet(command, async (path: string, options?: any) => { + expect(path).to.equal('/sandboxes/{sandboxId}/clones/{cloneId}'); + expect(options?.params?.path?.sandboxId).to.equal('test-sandbox-id'); + expect(options?.params?.path?.cloneId).to.equal('aaaa-001-1642780893121'); + return { + data: {data: mockClone}, + response: new Response(), + }; + }); + + const result = await command.run(); + + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockClone); + }); + + it('should display formatted details in non-JSON mode', async () => { + const command = new CloneGet(['test-sandbox-id', 'aaaa-001-1642780893121'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id', cloneId: 'aaaa-001-1642780893121'}; + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const outputs: string[] = []; + const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ..._args: string[]) => { + if (str) { + const output = Array.isArray(str) ? str.join('') : str; + outputs.push(output); + } + }); + + const mockClone = { + cloneId: 'aaaa-001-1642780893121', + status: 'IN_PROGRESS', + realm: 'aaaa', + sourceInstance: 'aaaa-000', + sourceInstanceId: '11111111-2222-3333-4444-555555555555', + targetInstance: 'aaaa-001', + targetInstanceId: '66666666-7777-8888-9999-000000000000', + targetProfile: 'large', + progressPercentage: 75, + elapsedTimeInSec: 1800, + createdAt: '2025-02-27T10:00:00Z', + createdBy: 'test@example.com', + lastUpdated: '2025-02-27T10:30:00Z', + }; + + stubOdsClientGet(command, async () => { + return { + data: {data: mockClone}, + response: new Response(), + }; + }); + + await runSilent(() => command.run()); + + stdoutStub.restore(); + + const combinedOutput = outputs.join('\n'); + expect(combinedOutput).to.include('Clone Details'); + expect(combinedOutput).to.include('Clone ID:'); + expect(combinedOutput).to.include('aaaa-001-1642780893121'); + expect(combinedOutput).to.include('Progress:'); + expect(combinedOutput).to.include('75%'); + expect(combinedOutput).to.include('Source Instance:'); + expect(combinedOutput).to.include('aaaa-000'); + expect(combinedOutput).to.include('Source Instance ID:'); + expect(combinedOutput).to.include('11111111-2222-3333-4444-555555555555'); + expect(combinedOutput).to.include('Target Instance:'); + expect(combinedOutput).to.include('aaaa-001'); + expect(combinedOutput).to.include('Target Instance ID:'); + expect(combinedOutput).to.include('66666666-7777-8888-9999-000000000000'); + expect(combinedOutput).to.include('Realm:'); + expect(combinedOutput).to.include('aaaa'); + }); + + it('should not display additional info in non-JSON mode', async () => { + const command = new CloneGet(['test-sandbox-id', 'aaaa-001-1642780893121'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id', cloneId: 'aaaa-001-1642780893121'}; + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const outputs: string[] = []; + const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ..._args: string[]) => { + if (str) { + const output = Array.isArray(str) ? str.join('') : str; + outputs.push(output); + } + }); + + const mockClone = { + cloneId: 'aaaa-001-1642780893121', + status: 'COMPLETED', + realm: 'aaaa', + sourceInstance: 'aaaa-000', + sourceInstanceId: '11111111-2222-3333-4444-555555555555', + targetInstance: 'aaaa-001', + targetInstanceId: '66666666-7777-8888-9999-000000000000', + progressPercentage: 100, + createdAt: '2025-02-27T10:00:00Z', + createdBy: 'test@example.com', + lastKnownState: 'finalizing', + customCodeVersion: '1.0.0', + storefrontCount: 5, + filesystemUsageSize: 1_073_741_824, + databaseTransferSize: 2_147_483_648, + }; + + stubOdsClientGet(command, async () => { + return { + data: {data: mockClone}, + response: new Response(), + }; + }); + + await runSilent(() => command.run()); + + stdoutStub.restore(); + + const combinedOutput = outputs.join('\n'); + // Should display essential fields + expect(combinedOutput).to.include('Clone ID:'); + expect(combinedOutput).to.include('aaaa-001-1642780893121'); + expect(combinedOutput).to.include('Source Instance:'); + expect(combinedOutput).to.include('aaaa-000'); + expect(combinedOutput).to.include('Target Instance:'); + expect(combinedOutput).to.include('aaaa-001'); + expect(combinedOutput).to.include('Realm:'); + expect(combinedOutput).to.include('aaaa'); + expect(combinedOutput).to.include('Progress:'); + expect(combinedOutput).to.include('100%'); + + // Should display additional fields in non-JSON mode + expect(combinedOutput).to.include('Custom Code Version'); + expect(combinedOutput).to.include('1.0.0'); + expect(combinedOutput).to.include('Storefront Count'); + expect(combinedOutput).to.include('5'); + expect(combinedOutput).to.include('Filesystem Usage Size'); + expect(combinedOutput).to.include('1073741824'); + expect(combinedOutput).to.include('Database Transfer Size'); + expect(combinedOutput).to.include('2147483648'); + }); + + it('should handle missing optional fields gracefully', async () => { + const command = new CloneGet(['test-sandbox-id', 'aaaa-001-1642780893121'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id', cloneId: 'aaaa-001-1642780893121'}; + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const outputs: string[] = []; + const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ..._args: string[]) => { + if (str) { + const output = Array.isArray(str) ? str.join('') : str; + outputs.push(output); + } + }); + + const mockClone = { + cloneId: 'aaaa-001-1642780893121', + status: 'PENDING', + }; + + stubOdsClientGet(command, async () => { + return { + data: {data: mockClone}, + response: new Response(), + }; + }); + + await runSilent(() => command.run()); + + stdoutStub.restore(); + + const combinedOutput = outputs.join('\n'); + expect(combinedOutput).to.include('Clone ID:'); + expect(combinedOutput).to.include('aaaa-001-1642780893121'); + // Fields with undefined values should not be displayed + // Only the Clone ID field should be present since other fields are undefined + }); + }); + + describe('error handling', () => { + it('should throw error when API call fails', async () => { + const command = new CloneGet(['test-sandbox-id', 'aaaa-001-1642780893121'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id', cloneId: 'aaaa-001-1642780893121'}; + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubResolveSandboxId(command, async (id) => id); + + stubOdsClientGet(command, async () => { + return {data: null, error: {message: 'API Error'}, response: new Response()}; + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Failed to get clone details'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts new file mode 100644 index 00000000..1d816c38 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts @@ -0,0 +1,274 @@ +/* + * 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 {ux} from '@oclif/core'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import CloneList, {COLUMNS} from '../../../../src/commands/sandbox/clone/list.js'; +import {runSilent} from '../../../helpers/test-setup.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClientGet(command: any, handler: (path: string, options?: any) => Promise): void { + Object.defineProperty(command, 'odsClient', { + value: { + GET: handler, + }, + configurable: true, + }); +} + +function stubResolveSandboxId(command: any, handler: (id: string) => Promise): void { + command.resolveSandboxId = handler; +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} + +describe('sandbox clone list', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(CloneList.description).to.be.a('string'); + expect(CloneList.description).to.include('clone'); + }); + + it('should enable JSON flag', () => { + expect(CloneList.enableJsonFlag).to.be.true; + }); + + it('should have sandboxId argument', () => { + expect(CloneList.args).to.have.property('sandboxId'); + expect(CloneList.args.sandboxId.required).to.be.true; + }); + + it('should have status flag', () => { + expect(CloneList.flags).to.have.property('status'); + expect(CloneList.flags.status.options).to.deep.equal(['Pending', 'InProgress', 'Failed', 'Completed']); + }); + }); + + describe('createdAt column formatting', () => { + const getCreatedAt = COLUMNS.createdAt.get; + + it('returns "-" when createdAt is missing', () => { + expect(getCreatedAt({} as any)).to.equal('-'); + }); + + it('returns YYYY-MM-DD when created more than 24 hours ago', () => { + const past = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(); + const result = getCreatedAt({createdAt: past} as any); + expect(result).to.match(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('returns YYYY-MM-DD HH:mm when created within 24 hours', () => { + const recent = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const result = getCreatedAt({createdAt: recent} as any); + expect(result).to.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); + + it('returns correct UTC time in YYYY-MM-DD HH:mm format', () => { + const recentTime = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // 30 minutes ago + const d = new Date(recentTime); + const expectedDate = d.toISOString().slice(0, 10); + const expectedHH = String(d.getUTCHours()).padStart(2, '0'); + const expectedMM = String(d.getUTCMinutes()).padStart(2, '0'); + + const result = getCreatedAt({createdAt: recentTime} as any); + expect(result).to.equal(`${expectedDate} ${expectedHH}:${expectedMM}`); + }); + }); + + describe('output formatting', () => { + it('should return clone list in JSON mode', async () => { + const command = new CloneList(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const mockClones = [ + { + cloneId: 'aaaa-001-1642780893121', + status: 'COMPLETED', + targetInstance: 'aaaa-001', + progressPercentage: 100, + createdAt: '2025-02-27T10:00:00Z', + }, + { + cloneId: 'aaaa-002-1642780893122', + status: 'IN_PROGRESS', + targetInstance: 'aaaa-002', + progressPercentage: 45, + createdAt: '2025-02-27T11:00:00Z', + }, + ]; + + const mockResponse = { + data: mockClones, + }; + + stubOdsClientGet(command, async (path: string, options?: any) => { + expect(path).to.equal('/sandboxes/{sandboxId}/clones'); + expect(options?.params?.path?.sandboxId).to.equal('test-sandbox-id'); + return {data: {data: mockResponse.data}, response: new Response()}; + }); + + const result = await command.run(); + + expect(result).to.have.property('data'); + expect(result.data).to.have.lengthOf(2); + expect(result.data![0].cloneId).to.equal('aaaa-001-1642780893121'); + expect(result.data![1].status).to.equal('IN_PROGRESS'); + }); + + it('should display formatted list in non-JSON mode', async () => { + const command = new CloneList(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) logs.push(msg); + }; + + // Stub ux.stdout to capture table output + const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ..._args: string[]) => { + if (str) { + const output = Array.isArray(str) ? str.join('') : str; + logs.push(output); + } + }); + + const mockClones = [ + { + cloneId: 'aaaa-001-1642780893121', + status: 'COMPLETED', + targetInstance: 'aaaa-001', + progressPercentage: 100, + createdAt: '2025-02-27T10:00:00Z', + }, + ]; + + stubOdsClientGet(command, async () => { + return {data: {data: mockClones}, response: new Response()}; + }); + + await runSilent(() => command.run()); + + stdoutStub.restore(); + + const combinedLogs = logs.join('\n'); + expect(combinedLogs).to.include('aaaa-001-1642780893121'); + }); + + it('should handle empty clone list', async () => { + const command = new CloneList(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) logs.push(msg); + }; + + stubOdsClientGet(command, async () => { + return {data: {data: []}, response: new Response()}; + }); + + await runSilent(() => command.run()); + + const combinedLogs = logs.join('\n'); + expect(combinedLogs).to.include('No clones found'); + }); + + it('should pass filter parameters to API', async () => { + const command = new CloneList(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = { + from: '2024-01-01', + to: '2024-12-31', + status: 'COMPLETED', + }; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubResolveSandboxId(command, async (id) => id); + + let capturedOptions: any; + + stubOdsClientGet(command, async (path: string, options?: any) => { + capturedOptions = options; + return {data: {data: []}, response: new Response()}; + }); + + await command.run(); + + expect(capturedOptions?.params?.query?.fromDate).to.equal('2024-01-01'); + expect(capturedOptions?.params?.query?.toDate).to.equal('2024-12-31'); + expect(capturedOptions?.params?.query?.status).to.equal('COMPLETED'); + }); + }); + + describe('error handling', () => { + it('should throw error when API call fails', async () => { + const command = new CloneList(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubResolveSandboxId(command, async (id) => id); + + stubOdsClientGet(command, async () => { + return {data: null, error: {message: 'API Error'}, response: new Response()}; + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Failed to list sandbox clones'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/get.test.ts b/packages/b2c-cli/test/commands/sandbox/get.test.ts index bf12a4e9..ea4b854e 100644 --- a/packages/b2c-cli/test/commands/sandbox/get.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/get.test.ts @@ -85,6 +85,12 @@ describe('sandbox get', () => { configurable: true, }); + // Mock flags + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + stubCommandConfigAndLogger(command); stubJsonEnabled(command, true); @@ -117,6 +123,11 @@ describe('sandbox get', () => { configurable: true, }); + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + stubCommandConfigAndLogger(command); stubJsonEnabled(command, false); @@ -151,6 +162,11 @@ describe('sandbox get', () => { configurable: true, }); + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + stubCommandConfigAndLogger(command); makeCommandThrowOnError(command); stubOdsClient(command, { @@ -176,6 +192,11 @@ describe('sandbox get', () => { configurable: true, }); + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + stubCommandConfigAndLogger(command); makeCommandThrowOnError(command); stubOdsClient(command, { @@ -201,6 +222,11 @@ describe('sandbox get', () => { configurable: true, }); + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + stubCommandConfigAndLogger(command); makeCommandThrowOnError(command); stubOdsClient(command, { @@ -220,5 +246,212 @@ describe('sandbox get', () => { expect(error.message).to.match(/Sandbox not found|Not Found/); } }); + + it('should return cloned sandbox data with clone fields', async () => { + const command = new SandboxGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'cloned-sandbox'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + + Object.defineProperty(command, 'resolveSandboxId', { + value: async (id: string) => id, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockClonedSandbox = { + id: '7f70ce44-9562-4394-921f-1e442cd85140', + realm: 'zzzv', + instance: '002', + state: 'started' as const, + hostName: 'zzzv-002.test01.dx.unified.demandware.net', + createdAt: '2026-03-02T12:50:23Z', + clonedFrom: 'zzzv-001', + sourceInstanceIdentifier: '81a4354c-db35-4168-8d6e-5e047fb06cc6', + links: { + bm: 'https://zzzv-002.test01.dx.unified.demandware.net/on/demandware.store/Sites-Site', + }, + }; + + stubOdsClient(command, { + GET: async () => ({ + data: {data: mockClonedSandbox}, + response: new Response(), + }), + }); + + const result = await runSilent(() => command.run()); + + // Verify the sandbox data includes clone fields + expect(result.id).to.equal('7f70ce44-9562-4394-921f-1e442cd85140'); + expect(result.clonedFrom).to.equal('zzzv-001'); + expect(result.sourceInstanceIdentifier).to.equal('81a4354c-db35-4168-8d6e-5e047fb06cc6'); + }); + + it('should return regular sandbox data without clone fields', async () => { + const command = new SandboxGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'regular-sandbox'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + + Object.defineProperty(command, 'resolveSandboxId', { + value: async (id: string) => id, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockRegularSandbox = { + id: 'sandbox-456', + realm: 'zzzv', + instance: '001', + state: 'started' as const, + hostName: 'zzzv-001.test01.dx.unified.demandware.net', + createdAt: '2026-03-01T10:00:00Z', + links: { + bm: 'https://zzzv-001.test01.dx.unified.demandware.net/on/demandware.store/Sites-Site', + }, + }; + + stubOdsClient(command, { + GET: async () => ({ + data: {data: mockRegularSandbox}, + response: new Response(), + }), + }); + + const result = await runSilent(() => command.run()); + + // Verify regular sandbox doesn't have clone fields + expect(result.id).to.equal('sandbox-456'); + expect(result.clonedFrom).to.be.undefined; + expect(result.sourceInstanceIdentifier).to.be.undefined; + }); + + it('should include cloneDetails when clone-details flag is set', async () => { + const command = new SandboxGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'cloned-sandbox-with-details'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {'clone-details': true}, + configurable: true, + }); + + Object.defineProperty(command, 'resolveSandboxId', { + value: async (id: string) => id, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockSandboxWithCloneDetails = { + id: 'cloned-sandbox-789', + realm: 'zzzv', + instance: '003', + state: 'started' as const, + clonedFrom: 'zzzv-001', + sourceInstanceIdentifier: '81a4354c-db35-4168-8d6e-5e047fb06cc6', + cloneDetails: { + cloneId: 'zzzv-003-1234567890123', + status: 'COMPLETED' as const, + targetProfile: 'large' as const, + createdAt: '2026-03-01T10:00:00Z', + createdBy: 'test@example.com', + progressPercentage: 100, + elapsedTimeInSec: 3600, + }, + }; + + // Capture the params passed to GET + let capturedParams: any; + stubOdsClient(command, { + async GET(path: string, options?: any) { + capturedParams = options?.params; + return { + data: {data: mockSandboxWithCloneDetails}, + response: new Response(), + }; + }, + }); + + const result = await runSilent(() => command.run()); + + // Verify the expand parameter was passed in the query + expect(capturedParams).to.have.property('query'); + expect(capturedParams.query).to.have.property('expand'); + expect(capturedParams.query.expand).to.deep.equal(['clonedetails']); + + // Verify cloneDetails is present in the result + expect(result.cloneDetails).to.exist; + expect(result.cloneDetails?.status).to.equal('COMPLETED'); + expect(result.cloneDetails?.progressPercentage).to.equal(100); + }); + + it('should not include expand parameter when flag is false', async () => { + const command = new SandboxGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {'clone-details': false}, + configurable: true, + }); + + Object.defineProperty(command, 'resolveSandboxId', { + value: async (id: string) => id, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + // Capture the params passed to GET + let capturedParams: any; + stubOdsClient(command, { + async GET(path: string, options?: any) { + capturedParams = options?.params; + return { + data: { + data: { + id: 'sandbox-123', + realm: 'zzzv', + state: 'started' as const, + }, + }, + response: new Response(), + }; + }, + }); + + await runSilent(() => command.run()); + + // Verify the query parameter was not passed + expect(capturedParams).to.not.have.property('query'); + }); }); }); diff --git a/packages/b2c-cli/test/commands/sandbox/list.test.ts b/packages/b2c-cli/test/commands/sandbox/list.test.ts index ba88b52b..49c024ac 100644 --- a/packages/b2c-cli/test/commands/sandbox/list.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/list.test.ts @@ -64,7 +64,7 @@ describe('sandbox list', () => { (command as any).flags = {}; const columns = (command as any).getSelectedColumns(); - expect(columns).to.deep.equal(['realm', 'instance', 'state', 'profile', 'created', 'eol', 'id']); + expect(columns).to.deep.equal(['realm', 'instance', 'state', 'profile', 'created', 'eol', 'id', 'isCloned']); }); it('should return all columns when --extended flag is set', () => { @@ -76,6 +76,7 @@ describe('sandbox list', () => { expect(columns).to.include('hostname'); expect(columns).to.include('createdBy'); expect(columns).to.include('autoScheduled'); + expect(columns).to.include('isCloned'); }); it('should return custom columns when --columns flag is set', () => { @@ -97,6 +98,22 @@ describe('sandbox list', () => { }); }); + describe('isCloned column formatting', () => { + const getIsCloned = COLUMNS.isCloned.get; + + it('returns "Yes" when sandbox has clonedFrom field', () => { + expect(getIsCloned({clonedFrom: 'zzzv-001'} as any)).to.equal('Yes'); + }); + + it('returns "No" when sandbox does not have clonedFrom field', () => { + expect(getIsCloned({} as any)).to.equal('No'); + }); + + it('returns "No" when clonedFrom is undefined', () => { + expect(getIsCloned({clonedFrom: undefined} as any)).to.equal('No'); + }); + }); + describe('eol column formatting', () => { const getEol = COLUMNS.eol.get; diff --git a/packages/b2c-tooling-sdk/specs/ods-api-v1.json b/packages/b2c-tooling-sdk/specs/ods-api-v1.json index 22bfcc0d..3a2cd364 100644 --- a/packages/b2c-tooling-sdk/specs/ods-api-v1.json +++ b/packages/b2c-tooling-sdk/specs/ods-api-v1.json @@ -24,6 +24,11 @@ "name": "Sandboxes", "x-sfdc-group-id": "sandboxes", "description": "Operations on the sandbox level." + }, + { + "name": "Cloning", + "x-sfdc-group-id": "cloning", + "description": "APIs for creating and managing sandbox clones." } ], "paths": { @@ -764,6 +769,21 @@ "tags": [ "Sandboxes" ], + "parameters": [ + { + "name": "expand", + "in": "query", + "description": "Additional information to include in the sandbox query. Available options: clonedetails", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["clonedetails"] + } + } + } + ], "responses": { "200": { "description": "Details on the sandbox (including its state).", @@ -1183,6 +1203,283 @@ ] } }, + "/sandboxes/{sandboxId}/clones": { + "parameters": [ + { + "$ref": "#/components/parameters/sandboxIdParam" + } + ], + "get": { + "operationId": "getSandboxesClone", + "summary": "List all cloned sandboxes for a specific sandbox.", + "description": "Return all cloned sandboxes for a specific sandbox. Optionally filter by date range using fromDate and toDate parameters. If fromDate is provided without toDate, toDate defaults to the current timestamp. If both are omitted, all clones are returned. All dates are processed in UTC timezone. Optionally filter by status.", + "tags": [ + "Cloning" + ], + "parameters": [ + { + "name": "fromDate", + "in": "query", + "description": "Filter clones created on or after this date (ISO 8601 date format, e.g., 2024-01-01). When provided without toDate, toDate defaults to the current timestamp.", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "toDate", + "in": "query", + "description": "Filter clones created on or before this date (ISO 8601 date format, e.g., 2024-12-31). If omitted when fromDate is provided, defaults to current timestamp. Optional parameter.", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "status", + "in": "query", + "description": "Filter clones by status (Pending, InProgress, Failed, or Completed). If not provided, returns all clones regardless of status.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "Pending", + "InProgress", + "Failed", + "Completed" + ] + } + } + ], + "responses": { + "200": { + "description": "List of cloned sandboxes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxCloneListResponse" + } + } + } + }, + "400": { + "description": "The request parameters are invalid (bad request).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "The user doesn't have access to that realm.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "There isn't any sandbox with that ID.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "There were server errors during the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "AccountManager": [] + }, + { + "ClientCredentials": [] + } + ] + }, + "post": { + "operationId": "createSandboxClone", + "summary": "Create sandbox clone.", + "description": "Create a new sandbox clone for a specific sandbox.", + "tags": [ + "Cloning" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxCloneProvisioningRequestModel" + } + } + }, + "description": "Metadata about the new sandbox clone.", + "required": true + }, + "responses": { + "201": { + "description": "The sandbox clone creation has started.", + "headers": { + "Location": { + "schema": { + "type": "string" + }, + "description": "URI of the created sandbox clone." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxCloneCreateResponse" + } + } + } + }, + "400": { + "description": "The request parameters are invalid (bad request).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "The user doesn't have access to the realm.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "There isn't any sandbox with that ID.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "There were server errors during the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "AccountManager": [] + }, + { + "ClientCredentials": [] + } + ] + } + }, + "/sandboxes/{sandboxId}/clones/{cloneId}": { + "parameters": [ + { + "$ref": "#/components/parameters/sandboxIdParam" + }, + { + "$ref": "#/components/parameters/sandboxCloneIdParam" + } + ], + "get": { + "operationId": "getSandboxClone", + "summary": "Retrieve sandbox clone information.", + "description": "Return details on a specific cloned sandbox for a sandbox.", + "tags": [ + "Cloning" + ], + "responses": { + "200": { + "description": "Details on the cloned sandbox (including its state).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxCloneResponse" + } + } + } + }, + "400": { + "description": "The request parameters are invalid (bad request).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "The user doesn't have access to the requested realm.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "There isn't any sandbox with that ID.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "There were server errors during the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "AccountManager": [] + }, + { + "ClientCredentials": [] + } + ] + } + }, "/sandboxes/{sandboxId}/operations": { "parameters": [ { @@ -1711,6 +2008,16 @@ "format": "uuid" } }, + "sandboxCloneIdParam": { + "name": "cloneId", + "in": "path", + "required": true, + "description": "The sandbox clone unique ID. A unique identifier of the sandbox in the format: realm-instance-clone-timestamp (e.g., aaaa-002-1642780893121), where: realm is 4 lowercase letters, instance is 3-digit number, timestamp is in ddMMyyyyHHmm format (13 digits).", + "schema": { + "type": "string", + "pattern": "^[a-z]{4}-\\d{3}-\\d{13}$" + } + }, "operationIdParam": { "name": "operationId", "in": "path", @@ -1914,6 +2221,8 @@ "SandboxUsage", "SandboxStorage", "SandboxOperationList", + "SandboxCloneList", + "SandboxClone", "Status" ] }, @@ -2498,6 +2807,161 @@ } ] }, + "SandboxCloneListResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SandboxCloneGetModel" + } + } + } + } + ] + }, + "SandboxCloneResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SandboxCloneGetModel" + } + } + } + ] + }, + "SandboxCloneCreateResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/StatusResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SandboxCloneCreateModel" + } + } + } + ] + }, + "SandboxCloneGetModel": { + "type": "object", + "properties": { + "cloneId": { + "type": "string" + }, + "realm": { + "type": "string" + }, + "sourceInstance": { + "type": "string" + }, + "targetInstance": { + "type": "string" + }, + "sourceInstanceId": { + "type": "string" + }, + "targetInstanceId": { + "type": "string" + }, + "targetProfile": { + "$ref": "#/components/schemas/SandboxResourceProfile" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "string" + }, + "lastUpdated": { + "type": "string", + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/SandboxCloneState" + }, + "elapsedTimeInSec": { + "type": "integer" + }, + "progressPercentage": { + "type": "integer" + }, + "lastKnownState": { + "type": "string", + "description": "The last known clone processing state before completion or failure" + }, + "customCodeVersion": { + "type": "string" + }, + "storefrontCount": { + "type": "integer" + }, + "filesystemUsageSize": { + "type": "integer", + "format": "int64" + }, + "databaseTransferSize": { + "type": "integer", + "format": "int64" + } + } + }, + "SandboxCloneCreateModel": { + "type": "object", + "properties": { + "cloneId": { + "type": "string", + "example": "zyom-002-017-180620251331" + } + } + }, + "SandboxCloneProvisioningRequestModel": { + "type": "object", + "properties": { + "targetProfile": { + "$ref": "#/components/schemas/SandboxResourceProfile" + }, + "emails": { + "type": "array", + "items": { + "type": "string", + "pattern": "(.+)@(.+)" + }, + "example": [ + "email1@example.com", + "email2@example.com" + ] + }, + "ttl": { + "type": "integer", + "format": "int32", + "default": 24, + "description": "Number of hours for the sandbox clone lifetime. Valid values are: 0 or negative (infinite lifetime), or 24 hours and above. Values between 1 and 23 are not allowed. The TTL must also adhere to the maximum TTL configuration for the realm." + } + } + }, + "SandboxCloneState": { + "type": "string", + "enum": [ + "PENDING", + "IN_PROGRESS", + "COMPLETED", + "FAILED" + ] + }, "SandboxStorageModel": { "type": "object", "description": "Shows all filesystem storages and how much space is left on them.", @@ -2640,6 +3104,18 @@ }, "stopScheduler": { "$ref": "#/components/schemas/WeekdaySchedule" + }, + "clonedFrom": { + "type": "string", + "description": "The realm-instance identifier of the source sandbox from which this sandbox was cloned." + }, + "sourceInstanceIdentifier": { + "type": "string", + "description": "The UUID of the source sandbox from which this sandbox was cloned." + }, + "cloneDetails": { + "$ref": "#/components/schemas/SandboxCloneGetModel", + "description": "Detailed clone information if this sandbox was created by cloning another sandbox. Only present when expand=clonedetails is requested and the sandbox is a clone." } } }, diff --git a/packages/b2c-tooling-sdk/src/clients/ods.generated.ts b/packages/b2c-tooling-sdk/src/clients/ods.generated.ts index c2ab301d..3eaf6f1a 100644 --- a/packages/b2c-tooling-sdk/src/clients/ods.generated.ts +++ b/packages/b2c-tooling-sdk/src/clients/ods.generated.ts @@ -303,6 +303,58 @@ export interface paths { patch?: never; trace?: never; }; + "/sandboxes/{sandboxId}/clones": { + parameters: { + query?: never; + header?: never; + path: { + /** @description The sandbox UUID. */ + sandboxId: components["parameters"]["sandboxIdParam"]; + }; + cookie?: never; + }; + /** + * List all cloned sandboxes for a specific sandbox. + * @description Return all cloned sandboxes for a specific sandbox. Optionally filter by date range using fromDate and toDate parameters. If fromDate is provided without toDate, toDate defaults to the current timestamp. If both are omitted, all clones are returned. All dates are processed in UTC timezone. Optionally filter by status. + */ + get: operations["getSandboxesClone"]; + put?: never; + /** + * Create sandbox clone. + * @description Create a new sandbox clone for a specific sandbox. + */ + post: operations["createSandboxClone"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxId}/clones/{cloneId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description The sandbox UUID. */ + sandboxId: components["parameters"]["sandboxIdParam"]; + /** @description The sandbox clone unique ID. A unique identifier of the sandbox in the format: realm-instance-clone-timestamp (e.g., aaaa-002-1642780893121), where: realm is 4 lowercase letters, instance is 3-digit number, timestamp is in ddMMyyyyHHmm format (13 digits). */ + cloneId: components["parameters"]["sandboxCloneIdParam"]; + }; + cookie?: never; + }; + /** + * Retrieve sandbox clone information. + * @description Return details on a specific cloned sandbox for a sandbox. + */ + get: operations["getSandboxClone"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/sandboxes/{sandboxId}/operations": { parameters: { query?: never; @@ -438,7 +490,7 @@ export interface components { * @description Type of response object. * @enum {string} */ - kind: "ApiVersion" | "UserInfo" | "SystemInfo" | "Realm" | "RealmConfiguration" | "RealmUsage" | "MultiRealmUsage" | "Sandbox" | "SandboxList" | "SandboxAlias" | "SandboxAliasList" | "SandboxSettings" | "SandboxUsage" | "SandboxStorage" | "SandboxOperationList" | "Status"; + kind: "ApiVersion" | "UserInfo" | "SystemInfo" | "Realm" | "RealmConfiguration" | "RealmUsage" | "MultiRealmUsage" | "Sandbox" | "SandboxList" | "SandboxAlias" | "SandboxAliasList" | "SandboxSettings" | "SandboxUsage" | "SandboxStorage" | "SandboxOperationList" | "SandboxCloneList" | "SandboxClone" | "Status"; /** * Format: int32 * @description Response code sent along with the status. @@ -740,6 +792,62 @@ export interface components { SandboxResponse: components["schemas"]["StatusResponse"] & { data?: components["schemas"]["SandboxModel"]; }; + SandboxCloneListResponse: components["schemas"]["StatusResponse"] & { + data?: components["schemas"]["SandboxCloneGetModel"][]; + }; + SandboxCloneResponse: components["schemas"]["StatusResponse"] & { + data?: components["schemas"]["SandboxCloneGetModel"]; + }; + SandboxCloneCreateResponse: components["schemas"]["StatusResponse"] & { + data?: components["schemas"]["SandboxCloneCreateModel"]; + }; + SandboxCloneGetModel: { + cloneId?: string; + realm?: string; + sourceInstance?: string; + targetInstance?: string; + sourceInstanceId?: string; + targetInstanceId?: string; + targetProfile?: components["schemas"]["SandboxResourceProfile"]; + /** Format: date-time */ + createdAt?: string; + createdBy?: string; + /** Format: date-time */ + lastUpdated?: string; + status?: components["schemas"]["SandboxCloneState"]; + elapsedTimeInSec?: number; + progressPercentage?: number; + /** @description The last known clone processing state before completion or failure */ + lastKnownState?: string; + customCodeVersion?: string; + storefrontCount?: number; + /** Format: int64 */ + filesystemUsageSize?: number; + /** Format: int64 */ + databaseTransferSize?: number; + }; + SandboxCloneCreateModel: { + /** @example zyom-002-017-180620251331 */ + cloneId?: string; + }; + SandboxCloneProvisioningRequestModel: { + targetProfile?: components["schemas"]["SandboxResourceProfile"]; + /** + * @example [ + * "email1@example.com", + * "email2@example.com" + * ] + */ + emails?: string[]; + /** + * Format: int32 + * @description Number of hours for the sandbox clone lifetime. Valid values are: 0 or negative (infinite lifetime), or 24 hours and above. Values between 1 and 23 are not allowed. The TTL must also adhere to the maximum TTL configuration for the realm. + * @default 24 + */ + ttl: number; + }; + /** @enum {string} */ + SandboxCloneState: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED"; /** @description Shows all filesystem storages and how much space is left on them. */ SandboxStorageModel: { [key: string]: components["schemas"]["StorageUsageModel"]; @@ -814,6 +922,12 @@ export interface components { }; startScheduler?: components["schemas"]["WeekdaySchedule"]; stopScheduler?: components["schemas"]["WeekdaySchedule"]; + /** @description The realm-instance identifier of the source sandbox from which this sandbox was cloned. */ + clonedFrom?: string; + /** @description The UUID of the source sandbox from which this sandbox was cloned. */ + sourceInstanceIdentifier?: string; + /** @description Detailed clone information if this sandbox was created by cloning another sandbox. Only present when expand=clonedetails is requested and the sandbox is a clone. */ + cloneDetails?: components["schemas"]["SandboxCloneGetModel"]; }; GranularUsage: { /** @description start of the usage being returned */ @@ -1300,6 +1414,8 @@ export interface components { sandboxIdParam: string; /** @description The sandbox alias UUID. */ sandboxAliasIdParam: string; + /** @description The sandbox clone unique ID. A unique identifier of the sandbox in the format: realm-instance-clone-timestamp (e.g., aaaa-002-1642780893121), where: realm is 4 lowercase letters, instance is 3-digit number, timestamp is in ddMMyyyyHHmm format (13 digits). */ + sandboxCloneIdParam: string; /** @description The operation UUID. */ operationIdParam: string; /** @description The page to access in a paged response. Page numbers start with '0', which is the default value. */ @@ -1836,7 +1952,10 @@ export interface operations { }; getSandbox: { parameters: { - query?: never; + query?: { + /** @description Additional information to include in the sandbox query. Available options: clonedetails */ + expand?: "clonedetails"[]; + }; header?: never; path: { /** @description The sandbox UUID. */ @@ -2180,6 +2299,199 @@ export interface operations { }; }; }; + getSandboxesClone: { + parameters: { + query?: { + /** @description Filter clones created on or after this date (ISO 8601 date format, e.g., 2024-01-01). When provided without toDate, toDate defaults to the current timestamp. */ + fromDate?: string; + /** @description Filter clones created on or before this date (ISO 8601 date format, e.g., 2024-12-31). If omitted when fromDate is provided, defaults to current timestamp. Optional parameter. */ + toDate?: string; + /** @description Filter clones by status (Pending, InProgress, Failed, or Completed). If not provided, returns all clones regardless of status. */ + status?: "Pending" | "InProgress" | "Failed" | "Completed"; + }; + header?: never; + path: { + /** @description The sandbox UUID. */ + sandboxId: components["parameters"]["sandboxIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of cloned sandboxes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxCloneListResponse"]; + }; + }; + /** @description The request parameters are invalid (bad request). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The user doesn't have access to that realm. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description There isn't any sandbox with that ID. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description There were server errors during the request. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createSandboxClone: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The sandbox UUID. */ + sandboxId: components["parameters"]["sandboxIdParam"]; + }; + cookie?: never; + }; + /** @description Metadata about the new sandbox clone. */ + requestBody: { + content: { + "application/json": components["schemas"]["SandboxCloneProvisioningRequestModel"]; + }; + }; + responses: { + /** @description The sandbox clone creation has started. */ + 201: { + headers: { + /** @description URI of the created sandbox clone. */ + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxCloneCreateResponse"]; + }; + }; + /** @description The request parameters are invalid (bad request). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The user doesn't have access to the realm. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description There isn't any sandbox with that ID. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description There were server errors during the request. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getSandboxClone: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The sandbox UUID. */ + sandboxId: components["parameters"]["sandboxIdParam"]; + /** @description The sandbox clone unique ID. A unique identifier of the sandbox in the format: realm-instance-clone-timestamp (e.g., aaaa-002-1642780893121), where: realm is 4 lowercase letters, instance is 3-digit number, timestamp is in ddMMyyyyHHmm format (13 digits). */ + cloneId: components["parameters"]["sandboxCloneIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Details on the cloned sandbox (including its state). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxCloneResponse"]; + }; + }; + /** @description The request parameters are invalid (bad request). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The user doesn't have access to the requested realm. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description There isn't any sandbox with that ID. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description There were server errors during the request. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getSandboxOperations: { parameters: { query?: { diff --git a/packages/b2c-tooling-sdk/src/plugins/loader.ts b/packages/b2c-tooling-sdk/src/plugins/loader.ts index 406eac27..deab783c 100644 --- a/packages/b2c-tooling-sdk/src/plugins/loader.ts +++ b/packages/b2c-tooling-sdk/src/plugins/loader.ts @@ -55,7 +55,7 @@ export function createHookContext(options: HookContextOptions = {}): HookContext * esbuild transforms `import()` to `require()` in CJS output, which cannot * load ESM plugins. Using `new Function` preserves the native dynamic import. */ -// eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynamicImport = new Function('specifier', 'return import(specifier)') as ( specifier: string, ) => Promise>;