From 1fa1f0b54cbc94619e44302feba2a8b4dff62d93 Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Mon, 2 Mar 2026 14:25:16 +0530 Subject: [PATCH 1/8] @W-21319709 adding cloning support --- docs/cli/sandbox.md | 203 ++++++++ .../b2c-cli/src/commands/ods/clone/create.ts | 168 +++++++ .../b2c-cli/src/commands/ods/clone/get.ts | 100 ++++ .../b2c-cli/src/commands/ods/clone/list.ts | 175 +++++++ .../test/commands/ods/clone/create.test.ts | 445 +++++++++++++++++ .../test/commands/ods/clone/get.test.ts | 312 ++++++++++++ .../test/commands/ods/clone/list.test.ts | 244 ++++++++++ .../b2c-tooling-sdk/specs/ods-api-v1.json | 449 ++++++++++++++++++ .../src/clients/ods.generated.ts | 305 +++++++++++- 9 files changed, 2400 insertions(+), 1 deletion(-) create mode 100644 packages/b2c-cli/src/commands/ods/clone/create.ts create mode 100644 packages/b2c-cli/src/commands/ods/clone/get.ts create mode 100644 packages/b2c-cli/src/commands/ods/clone/list.ts create mode 100644 packages/b2c-cli/test/commands/ods/clone/create.test.ts create mode 100644 packages/b2c-cli/test/commands/ods/clone/get.test.ts create mode 100644 packages/b2c-cli/test/commands/ods/clone/list.test.ts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 55524994..7ef0eabe 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -647,6 +647,209 @@ b2c sandbox alias delete zzzv-123 alias-uuid-here --json --- +## Sandbox Cloning + +Sandbox cloning commands let you create copies of existing sandboxes with their data and configuration. A clone creates a new sandbox instance with the same data as the source sandbox, allowing you to test changes or create development environments without affecting the original. + +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`, `status`, `targetInstance`, `targetProfile`, `progressPercentage`, `createdAt`, `createdBy`, `lastUpdated`, `elapsedTimeInSec`, `sourceInstance`, `realm` + +**Default columns:** `cloneId`, `status`, `targetInstance`, `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 Status Target Instance Progress % Created At +──────────────────────────────────────────────────────────────────────────────── +aaaa-001-1642780893121 COMPLETED aaaa-001 100% 2/27/2025, 10:00:00 AM +aaaa-002-1642780893122 IN_PROGRESS aaaa-002 75% 2/27/2025, 11:00:00 AM +``` + +### 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`). If not specified, uses the source sandbox's profile. | 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 (still uses source profile) +b2c sandbox clone create zzzv-123 --ttl 48 + +# Create a clone with 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 --target-profile medium --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 + +- Cloning can take significant time depending on sandbox size and data volume +- If `--target-profile` is not specified, the clone will use the same resource profile as the source sandbox +- The TTL must be 0 or negative (infinite), or 24 hours or greater. Values between 1-23 are rejected +- Notification emails will receive updates about the clone progress +- 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 essential clone information in a formatted table: + +``` +Clone Details +────────────────────────────────────────────────── +Clone ID: aaaa-002-1642780893121 +Source Instance: aaaa-000 +Target Instance: aaaa-002 +Realm: aaaa +Progress: 75% +Created At: 2/27/2025, 10:00:00 AM +Created By: user@example.com +``` + +For detailed information including status, timing, filesystem usage, and other 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: diff --git a/packages/b2c-cli/src/commands/ods/clone/create.ts b/packages/b2c-cli/src/commands/ods/clone/create.ts new file mode 100644 index 00000000..8055accf --- /dev/null +++ b/packages/b2c-cli/src/commands/ods/clone/create.ts @@ -0,0 +1,168 @@ +/* + * 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 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 args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or friendly format like realm-instance) to clone from', + required: true, + }), + }; + + 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': targetProfileFlag, 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); + + // Determine target profile - fetch from source sandbox if not provided + let targetProfile = targetProfileFlag; + + if (!targetProfile) { + this.log(t('commands.clone.create.fetchingSource', 'Fetching source sandbox profile...')); + + const sourceResult = await this.odsClient.GET('/sandboxes/{sandboxId}', { + params: {path: {sandboxId}}, + }); + + if (!sourceResult.data?.data?.resourceProfile) { + this.error( + t( + 'commands.clone.create.noSourceProfile', + 'Unable to determine source sandbox profile. Please specify --target-profile explicitly.', + ), + ); + } + + targetProfile = sourceResult.data.data.resourceProfile; + + if (!this.jsonEnabled()) { + this.log( + t('commands.clone.create.usingSourceProfile', 'Using source sandbox profile: {{profile}}', { + profile: targetProfile, + }), + ); + } + } + + // Validate that profile is one of the allowed values + const validProfiles = ['medium', 'large', 'xlarge', 'xxlarge']; + if (!validProfiles.includes(targetProfile)) { + this.error( + t( + 'commands.clone.create.invalidProfile', + 'Invalid target profile "{{profile}}". Must be one of: {{validProfiles}}', + {profile: targetProfile, validProfiles: validProfiles.join(', ')}, + ), + ); + } + + this.log(t('commands.clone.create.creating', 'Creating sandbox clone...')); + + // Prepare request body + const requestBody: { + targetProfile?: 'medium' | 'large' | 'xlarge' | 'xxlarge'; + emails?: string[]; + ttl: number; + } = { + targetProfile: targetProfile as 'medium' | 'large' | 'xlarge' | 'xxlarge', + ttl, + }; + + 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/ods/clone/get.ts b/packages/b2c-cli/src/commands/ods/clone/get.ts new file mode 100644 index 00000000..5d9d394a --- /dev/null +++ b/packages/b2c-cli/src/commands/ods/clone/get.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {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 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', + ]; + + 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, + }), + }; + + 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 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: [string, string | undefined][] = [ + ['Clone ID', clone?.cloneId], + ['Source Instance', clone?.sourceInstance], + ['Target Instance', clone?.targetInstance], + ['Realm', clone?.realm], + [ + 'Progress', + clone?.progressPercentage !== undefined ? `${clone.progressPercentage}%` : '-', + ], + ['Created At', clone?.createdAt ? new Date(clone.createdAt).toLocaleString() : undefined], + ['Created By', clone?.createdBy], + ]; + + 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]}); + } + } + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/ods/clone/list.ts b/packages/b2c-cli/src/commands/ods/clone/list.ts new file mode 100644 index 00000000..423a49f9 --- /dev/null +++ b/packages/b2c-cli/src/commands/ods/clone/list.ts @@ -0,0 +1,175 @@ +/* + * 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']; + +const COLUMNS: Record> = { + cloneId: { + header: 'Clone ID', + get: (c) => c.cloneId || '-', + }, + status: { + header: 'Status', + get: (c) => c.status || '-', + }, + sourceInstance: { + header: 'Source Instance', + get: (c) => c.sourceInstance || '-', + }, + targetInstance: { + header: 'Target Instance', + get: (c) => c.targetInstance || '-', + }, + targetProfile: { + header: 'Profile', + get: (c) => c.targetProfile || '-', + }, + progressPercentage: { + header: 'Progress %', + get: (c) => (c.progressPercentage !== undefined ? `${c.progressPercentage}%` : '-'), + }, + createdAt: { + header: 'Created At', + get: (c) => (c.createdAt ? new Date(c.createdAt).toLocaleString() : '-'), + }, + createdBy: { + header: 'Created By', + get: (c) => c.createdBy || '-', + }, + 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() : '-'), + }, + realm: { + header: 'Realm', + get: (c) => c.realm || '-', + }, +}; + +const DEFAULT_COLUMNS = ['cloneId', 'status', 'sourceInstance', 'targetInstance', 'progressPercentage', 'createdAt']; + +/** + * Command to list sandbox clones for a specific sandbox. + */ +export default class CloneList extends OdsCommand { + 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 args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or friendly format like realm-instance)', + required: true, + }), + }; + + 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 'Pending' | 'InProgress' | 'Failed' | 'Completed' | 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); + + this.log( + t('commands.clone.list.total', '\nTotal: {{total}} clone(s)', {total: clones.length}), + ); + + 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/test/commands/ods/clone/create.test.ts b/packages/b2c-cli/test/commands/ods/clone/create.test.ts new file mode 100644 index 00000000..4b7962fc --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/clone/create.test.ts @@ -0,0 +1,445 @@ +/* + * 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/ods/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 stubOdsClient( + command: any, + postHandler: (path: string, options?: any) => Promise, + getHandler: (path: string, options?: any) => Promise, +): void { + Object.defineProperty(command, 'odsClient', { + value: { + POST: postHandler, + GET: getHandler, + }, + 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('ods 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 use source sandbox profile 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; + let getCallCount = 0; + + stubOdsClient( + command, + async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }, + async (path: string) => { + getCallCount++; + expect(path).to.equal('/sandboxes/{sandboxId}'); + return { + data: { + data: { + id: 'test-sandbox-id', + resourceProfile: 'large', + }, + }, + response: new Response(), + }; + }, + ); + + await command.run(); + + expect(getCallCount).to.equal(1); + expect(capturedBody.targetProfile).to.equal('large'); + }); + + 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; + let getCallCount = 0; + + stubOdsClient( + command, + async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }, + async () => { + getCallCount++; + return {data: null, response: new Response()}; + }, + ); + + await command.run(); + + // Should NOT call GET when profile is explicitly provided + expect(getCallCount).to.equal(0); + expect(capturedBody.targetProfile).to.equal('medium'); + }); + + it('should error when source sandbox has no profile', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {ttl: 24}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubResolveSandboxId(command, async (id) => id); + + stubOdsClient( + command, + async () => { + return {data: null, response: new Response()}; + }, + async () => { + return { + data: {data: {}}, // No resourceProfile + response: new Response(), + }; + }, + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Unable to determine source sandbox profile'); + } + }); + + it('should validate profile is allowed value', async () => { + const command = new CloneCreate(['test-sandbox-id'], {} as any); + (command as any).args = {sandboxId: 'test-sandbox-id'}; + (command as any).flags = {ttl: 24}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubResolveSandboxId(command, async (id) => id); + + stubOdsClient( + command, + async () => { + return {data: null, response: new Response()}; + }, + async () => { + return { + data: { + data: { + resourceProfile: 'invalid-profile', + }, + }, + response: new Response(), + }; + }, + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Invalid target profile'); + } + }); + }); + + 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/ods/clone/get.test.ts b/packages/b2c-cli/test/commands/ods/clone/get.test.ts new file mode 100644 index 00000000..7a5e0399 --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/clone/get.test.ts @@ -0,0 +1,312 @@ +/* + * 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/ods/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('ods 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', + targetInstance: 'aaaa-001', + 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: 1073741824, // 1 GB + databaseTransferSize: 2147483648, // 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', + targetInstance: 'aaaa-001', + 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('Target Instance:'); + expect(combinedOutput).to.include('aaaa-001'); + expect(combinedOutput).to.include('Realm:'); + expect(combinedOutput).to.include('aaaa'); + expect(combinedOutput).to.include('Created By:'); + expect(combinedOutput).to.include('test@example.com'); + }); + + 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', + targetInstance: 'aaaa-001', + progressPercentage: 100, + createdAt: '2025-02-27T10:00:00Z', + createdBy: 'test@example.com', + lastKnownState: 'finalizing', + customCodeVersion: '1.0.0', + storefrontCount: 5, + filesystemUsageSize: 1073741824, + databaseTransferSize: 2147483648, + }; + + 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%'); + expect(combinedOutput).to.include('Created By:'); + expect(combinedOutput).to.include('test@example.com'); + + // Should NOT display additional fields in non-JSON mode + expect(combinedOutput).to.not.include('Last Known State'); + expect(combinedOutput).to.not.include('Custom Code Version'); + expect(combinedOutput).to.not.include('Storefront Count'); + expect(combinedOutput).to.not.include('Filesystem Usage'); + expect(combinedOutput).to.not.include('Database Transfer Size'); + }); + + 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/ods/clone/list.test.ts b/packages/b2c-cli/test/commands/ods/clone/list.test.ts new file mode 100644 index 00000000..a08e4e69 --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/clone/list.test.ts @@ -0,0 +1,244 @@ +/* + * 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 from '../../../../src/commands/ods/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('ods 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('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'); + expect(combinedLogs).to.include('Total: 1 clone(s)'); + }); + + 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-tooling-sdk/specs/ods-api-v1.json b/packages/b2c-tooling-sdk/specs/ods-api-v1.json index 22bfcc0d..e685e78b 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": { @@ -1183,6 +1188,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 +1993,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 +2206,8 @@ "SandboxUsage", "SandboxStorage", "SandboxOperationList", + "SandboxCloneList", + "SandboxClone", "Status" ] }, @@ -2498,6 +2792,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.", diff --git a/packages/b2c-tooling-sdk/src/clients/ods.generated.ts b/packages/b2c-tooling-sdk/src/clients/ods.generated.ts index c2ab301d..4d0adcb2 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"]; @@ -1300,6 +1408,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. */ @@ -2180,6 +2290,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?: { From 136910330d05e3a7aabff012f19646068a9b2eb7 Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Mon, 2 Mar 2026 14:45:28 +0530 Subject: [PATCH 2/8] updated doc --- docs/cli/sandbox.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 7ef0eabe..e3bdc957 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -649,7 +649,13 @@ b2c sandbox alias delete zzzv-123 alias-uuid-here --json ## Sandbox Cloning -Sandbox cloning commands let you create copies of existing sandboxes with their data and configuration. A clone creates a new sandbox instance with the same data as the source sandbox, allowing you to test changes or create development environments without affecting the original. +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: @@ -788,7 +794,9 @@ To check the clone status, run: #### Notes -- Cloning can take significant time depending on sandbox size and data volume +- **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 - If `--target-profile` is not specified, the clone will use the same resource profile as the source sandbox - The TTL must be 0 or negative (infinite), or 24 hours or greater. Values between 1-23 are rejected - Notification emails will receive updates about the clone progress From a9409b93f26fa140a25b1d77766633325cbde11f Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Mon, 2 Mar 2026 14:57:07 +0530 Subject: [PATCH 3/8] fixing lint --- .../b2c-cli/src/commands/ods/clone/create.ts | 34 +++++++------------ .../b2c-cli/src/commands/ods/clone/get.ts | 30 ++++++---------- .../b2c-cli/src/commands/ods/clone/list.ts | 34 +++++++------------ .../test/commands/ods/clone/create.test.ts | 7 +--- .../test/commands/ods/clone/get.test.ts | 14 ++++---- .../test/commands/ods/clone/list.test.ts | 2 +- 6 files changed, 45 insertions(+), 76 deletions(-) diff --git a/packages/b2c-cli/src/commands/ods/clone/create.ts b/packages/b2c-cli/src/commands/ods/clone/create.ts index 8055accf..4429cd7a 100644 --- a/packages/b2c-cli/src/commands/ods/clone/create.ts +++ b/packages/b2c-cli/src/commands/ods/clone/create.ts @@ -12,10 +12,14 @@ import {t} from '../../../i18n/index.js'; * Command to create a sandbox clone. */ export default class CloneCreate extends OdsCommand { - static description = t( - 'commands.clone.create.description', - 'Create a new sandbox clone from an existing sandbox', - ); + 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; @@ -26,13 +30,6 @@ export default class CloneCreate extends OdsCommand { '<%= config.bin %> <%= command.id %> --target-profile large --ttl 48 --emails dev@example.com,qa@example.com', ]; - static args = { - sandboxId: Args.string({ - description: 'Sandbox ID (UUID or friendly format like realm-instance) to clone from', - required: true, - }), - }; - static flags = { 'target-profile': Flags.string({ description: 'Resource profile for the cloned sandbox (defaults to source sandbox profile)', @@ -116,11 +113,11 @@ export default class CloneCreate extends OdsCommand { // Prepare request body const requestBody: { - targetProfile?: 'medium' | 'large' | 'xlarge' | 'xxlarge'; + targetProfile?: 'large' | 'medium' | 'xlarge' | 'xxlarge'; emails?: string[]; ttl: number; } = { - targetProfile: targetProfile as 'medium' | 'large' | 'xlarge' | 'xxlarge', + targetProfile: targetProfile as 'large' | 'medium' | 'xlarge' | 'xxlarge', ttl, }; @@ -137,9 +134,7 @@ export default class CloneCreate extends OdsCommand { if (!result.data) { const message = getApiErrorMessage(result.error, result.response); - this.error( - t('commands.clone.create.error', 'Failed to create sandbox clone: {{message}}', {message}), - ); + this.error(t('commands.clone.create.error', 'Failed to create sandbox clone: {{message}}', {message})); } const cloneId = result.data.data?.cloneId; @@ -148,12 +143,7 @@ export default class CloneCreate extends OdsCommand { return {cloneId}; } - this.log( - t( - 'commands.clone.create.success', - '✓ Sandbox clone creation started successfully', - ), - ); + 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( diff --git a/packages/b2c-cli/src/commands/ods/clone/get.ts b/packages/b2c-cli/src/commands/ods/clone/get.ts index 5d9d394a..52a4feb2 100644 --- a/packages/b2c-cli/src/commands/ods/clone/get.ts +++ b/packages/b2c-cli/src/commands/ods/clone/get.ts @@ -15,18 +15,6 @@ type SandboxCloneGetModel = OdsComponents['schemas']['SandboxCloneGetModel']; * Command to get details of a specific sandbox clone. */ export default class CloneGet extends OdsCommand { - 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', - ]; - static args = { sandboxId: Args.string({ description: 'Sandbox ID (UUID or friendly format like realm-instance)', @@ -38,6 +26,15 @@ export default class CloneGet extends OdsCommand { }), }; + 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; @@ -54,9 +51,7 @@ export default class CloneGet extends OdsCommand { if (!result.data) { const message = getApiErrorMessage(result.error, result.response); - this.error( - t('commands.clone.get.error', 'Failed to get clone details: {{message}}', {message}), - ); + this.error(t('commands.clone.get.error', 'Failed to get clone details: {{message}}', {message})); } const clone = result.data.data; @@ -81,10 +76,7 @@ export default class CloneGet extends OdsCommand { ['Source Instance', clone?.sourceInstance], ['Target Instance', clone?.targetInstance], ['Realm', clone?.realm], - [ - 'Progress', - clone?.progressPercentage !== undefined ? `${clone.progressPercentage}%` : '-', - ], + ['Progress', clone?.progressPercentage === undefined ? '-' : `${clone.progressPercentage}%`], ['Created At', clone?.createdAt ? new Date(clone.createdAt).toLocaleString() : undefined], ['Created By', clone?.createdBy], ]; diff --git a/packages/b2c-cli/src/commands/ods/clone/list.ts b/packages/b2c-cli/src/commands/ods/clone/list.ts index 423a49f9..7855c9cb 100644 --- a/packages/b2c-cli/src/commands/ods/clone/list.ts +++ b/packages/b2c-cli/src/commands/ods/clone/list.ts @@ -33,7 +33,7 @@ const COLUMNS: Record> = { }, progressPercentage: { header: 'Progress %', - get: (c) => (c.progressPercentage !== undefined ? `${c.progressPercentage}%` : '-'), + get: (c) => (c.progressPercentage === undefined ? '-' : `${c.progressPercentage}%`), }, createdAt: { header: 'Created At', @@ -49,7 +49,7 @@ const COLUMNS: Record> = { }, elapsedTimeInSec: { header: 'Elapsed Time (sec)', - get: (c) => (c.elapsedTimeInSec !== undefined ? c.elapsedTimeInSec.toString() : '-'), + get: (c) => (c.elapsedTimeInSec === undefined ? '-' : c.elapsedTimeInSec.toString()), }, realm: { header: 'Realm', @@ -63,6 +63,13 @@ const DEFAULT_COLUMNS = ['cloneId', 'status', 'sourceInstance', 'targetInstance' * Command to list sandbox clones for a specific sandbox. */ export default class CloneList extends OdsCommand { + 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; @@ -74,13 +81,6 @@ export default class CloneList extends OdsCommand { '<%= config.bin %> <%= command.id %> --extended', ]; - static args = { - sandboxId: Args.string({ - description: 'Sandbox ID (UUID or friendly format like realm-instance)', - required: true, - }), - }; - static flags = { from: Flags.string({ description: 'Filter clones created on or after this date (ISO 8601 date format, e.g., 2024-01-01)', @@ -108,11 +108,7 @@ export default class CloneList extends OdsCommand { async run(): Promise<{data?: SandboxCloneGetModel[]}> { const {sandboxId: rawSandboxId} = this.args; - const { - from: fromDate, - to: toDate, - status, - } = this.flags; + const {from: fromDate, to: toDate, status} = this.flags; // Resolve sandbox ID (handles both UUID and friendly format) const sandboxId = await this.resolveSandboxId(rawSandboxId); @@ -125,16 +121,14 @@ export default class CloneList extends OdsCommand { query: { fromDate, toDate, - status: status as 'Pending' | 'InProgress' | 'Failed' | 'Completed' | undefined, + 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}), - ); + this.error(t('commands.clone.list.error', 'Failed to list sandbox clones: {{message}}', {message})); } if (this.jsonEnabled()) { @@ -151,9 +145,7 @@ export default class CloneList extends OdsCommand { const tableRenderer = new TableRenderer(COLUMNS); tableRenderer.render(clones, columns); - this.log( - t('commands.clone.list.total', '\nTotal: {{total}} clone(s)', {total: clones.length}), - ); + this.log(t('commands.clone.list.total', '\nTotal: {{total}} clone(s)', {total: clones.length})); return {data: clones}; } diff --git a/packages/b2c-cli/test/commands/ods/clone/create.test.ts b/packages/b2c-cli/test/commands/ods/clone/create.test.ts index 4b7962fc..d17b8fe8 100644 --- a/packages/b2c-cli/test/commands/ods/clone/create.test.ts +++ b/packages/b2c-cli/test/commands/ods/clone/create.test.ts @@ -92,12 +92,7 @@ describe('ods clone create', () => { 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', - ]); + expect(CloneCreate.flags['target-profile'].options).to.deep.equal(['medium', 'large', 'xlarge', 'xxlarge']); }); it('should have optional ttl flag with default', () => { diff --git a/packages/b2c-cli/test/commands/ods/clone/get.test.ts b/packages/b2c-cli/test/commands/ods/clone/get.test.ts index 7a5e0399..307a8cff 100644 --- a/packages/b2c-cli/test/commands/ods/clone/get.test.ts +++ b/packages/b2c-cli/test/commands/ods/clone/get.test.ts @@ -104,8 +104,8 @@ describe('ods clone get', () => { lastUpdated: '2025-02-27T11:00:00Z', customCodeVersion: '1.0.0', storefrontCount: 5, - filesystemUsageSize: 1073741824, // 1 GB - databaseTransferSize: 2147483648, // 2 GB + filesystemUsageSize: 1_073_741_824, // 1 GB + databaseTransferSize: 2_147_483_648, // 2 GB }; stubOdsClientGet(command, async (path: string, options?: any) => { @@ -133,7 +133,7 @@ describe('ods clone get', () => { stubResolveSandboxId(command, async (id) => id); const outputs: string[] = []; - const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ...args: 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); @@ -190,7 +190,7 @@ describe('ods clone get', () => { stubResolveSandboxId(command, async (id) => id); const outputs: string[] = []; - const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ...args: 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); @@ -209,8 +209,8 @@ describe('ods clone get', () => { lastKnownState: 'finalizing', customCodeVersion: '1.0.0', storefrontCount: 5, - filesystemUsageSize: 1073741824, - databaseTransferSize: 2147483648, + filesystemUsageSize: 1_073_741_824, + databaseTransferSize: 2_147_483_648, }; stubOdsClientGet(command, async () => { @@ -256,7 +256,7 @@ describe('ods clone get', () => { stubResolveSandboxId(command, async (id) => id); const outputs: string[] = []; - const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ...args: 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); diff --git a/packages/b2c-cli/test/commands/ods/clone/list.test.ts b/packages/b2c-cli/test/commands/ods/clone/list.test.ts index a08e4e69..fee50ab7 100644 --- a/packages/b2c-cli/test/commands/ods/clone/list.test.ts +++ b/packages/b2c-cli/test/commands/ods/clone/list.test.ts @@ -139,7 +139,7 @@ describe('ods clone list', () => { }; // Stub ux.stdout to capture table output - const stdoutStub = sinon.stub(ux, 'stdout').callsFake((str?: string | string[], ...args: string[]) => { + 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); From 7ad222b2888f53419567d0484e1210b3f794a27b Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Mon, 2 Mar 2026 16:03:36 +0530 Subject: [PATCH 4/8] updating doc --- docs/cli/sandbox.md | 5 ++--- .../b2c-cli/src/commands/{ods => sandbox}/clone/create.ts | 2 ++ packages/b2c-cli/src/commands/{ods => sandbox}/clone/get.ts | 2 ++ packages/b2c-cli/src/commands/{ods => sandbox}/clone/list.ts | 4 ++-- .../test/commands/{ods => sandbox}/clone/create.test.ts | 4 ++-- .../b2c-cli/test/commands/{ods => sandbox}/clone/get.test.ts | 4 ++-- .../test/commands/{ods => sandbox}/clone/list.test.ts | 5 ++--- 7 files changed, 14 insertions(+), 12 deletions(-) rename packages/b2c-cli/src/commands/{ods => sandbox}/clone/create.ts (99%) rename packages/b2c-cli/src/commands/{ods => sandbox}/clone/get.ts (98%) rename packages/b2c-cli/src/commands/{ods => sandbox}/clone/list.ts (98%) rename packages/b2c-cli/test/commands/{ods => sandbox}/clone/create.test.ts (99%) rename packages/b2c-cli/test/commands/{ods => sandbox}/clone/get.test.ts (99%) rename packages/b2c-cli/test/commands/{ods => sandbox}/clone/list.test.ts (97%) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index e3bdc957..7f67774f 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -799,7 +799,6 @@ To check the clone status, run: - The cloned sandbox is fully isolated with dedicated compute, storage, and database resources - If `--target-profile` is not specified, the clone will use the same resource profile as the source sandbox - The TTL must be 0 or negative (infinite), or 24 hours or greater. Values between 1-23 are rejected -- Notification emails will receive updates about the clone progress - The clone will be created as a new sandbox instance in the same realm ### b2c sandbox clone get @@ -873,12 +872,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/ods/clone/create.ts b/packages/b2c-cli/src/commands/sandbox/clone/create.ts similarity index 99% rename from packages/b2c-cli/src/commands/ods/clone/create.ts rename to packages/b2c-cli/src/commands/sandbox/clone/create.ts index 4429cd7a..36eb7321 100644 --- a/packages/b2c-cli/src/commands/ods/clone/create.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/create.ts @@ -12,6 +12,8 @@ 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', diff --git a/packages/b2c-cli/src/commands/ods/clone/get.ts b/packages/b2c-cli/src/commands/sandbox/clone/get.ts similarity index 98% rename from packages/b2c-cli/src/commands/ods/clone/get.ts rename to packages/b2c-cli/src/commands/sandbox/clone/get.ts index 52a4feb2..4ec34dab 100644 --- a/packages/b2c-cli/src/commands/ods/clone/get.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/get.ts @@ -15,6 +15,8 @@ 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)', diff --git a/packages/b2c-cli/src/commands/ods/clone/list.ts b/packages/b2c-cli/src/commands/sandbox/clone/list.ts similarity index 98% rename from packages/b2c-cli/src/commands/ods/clone/list.ts rename to packages/b2c-cli/src/commands/sandbox/clone/list.ts index 7855c9cb..0da73904 100644 --- a/packages/b2c-cli/src/commands/ods/clone/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/list.ts @@ -63,6 +63,8 @@ const DEFAULT_COLUMNS = ['cloneId', 'status', 'sourceInstance', 'targetInstance' * 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)', @@ -145,8 +147,6 @@ export default class CloneList extends OdsCommand { const tableRenderer = new TableRenderer(COLUMNS); tableRenderer.render(clones, columns); - this.log(t('commands.clone.list.total', '\nTotal: {{total}} clone(s)', {total: clones.length})); - return {data: clones}; } diff --git a/packages/b2c-cli/test/commands/ods/clone/create.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/create.test.ts similarity index 99% rename from packages/b2c-cli/test/commands/ods/clone/create.test.ts rename to packages/b2c-cli/test/commands/sandbox/clone/create.test.ts index d17b8fe8..ce643d6d 100644 --- a/packages/b2c-cli/test/commands/ods/clone/create.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/clone/create.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import sinon from 'sinon'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import CloneCreate from '../../../../src/commands/ods/clone/create.js'; +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 { @@ -64,7 +64,7 @@ function makeCommandThrowOnError(command: any): void { }; } -describe('ods clone create', () => { +describe('sandbox clone create', () => { beforeEach(() => { isolateConfig(); }); diff --git a/packages/b2c-cli/test/commands/ods/clone/get.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts similarity index 99% rename from packages/b2c-cli/test/commands/ods/clone/get.test.ts rename to packages/b2c-cli/test/commands/sandbox/clone/get.test.ts index 307a8cff..0c766924 100644 --- a/packages/b2c-cli/test/commands/ods/clone/get.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts @@ -8,7 +8,7 @@ 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/ods/clone/get.js'; +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 { @@ -50,7 +50,7 @@ function makeCommandThrowOnError(command: any): void { }; } -describe('ods clone get', () => { +describe('sandbox clone get', () => { beforeEach(() => { isolateConfig(); }); diff --git a/packages/b2c-cli/test/commands/ods/clone/list.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts similarity index 97% rename from packages/b2c-cli/test/commands/ods/clone/list.test.ts rename to packages/b2c-cli/test/commands/sandbox/clone/list.test.ts index fee50ab7..4db5b353 100644 --- a/packages/b2c-cli/test/commands/ods/clone/list.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts @@ -8,7 +8,7 @@ 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 from '../../../../src/commands/ods/clone/list.js'; +import CloneList from '../../../../src/commands/sandbox/clone/list.js'; import {runSilent} from '../../../helpers/test-setup.js'; function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { @@ -50,7 +50,7 @@ function makeCommandThrowOnError(command: any): void { }; } -describe('ods clone list', () => { +describe('sandbox clone list', () => { beforeEach(() => { isolateConfig(); }); @@ -166,7 +166,6 @@ describe('ods clone list', () => { const combinedLogs = logs.join('\n'); expect(combinedLogs).to.include('aaaa-001-1642780893121'); - expect(combinedLogs).to.include('Total: 1 clone(s)'); }); it('should handle empty clone list', async () => { From 9c740405a0036bb8bfdcd416b7e2e9ddac5d7c98 Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Mon, 2 Mar 2026 20:18:35 +0530 Subject: [PATCH 5/8] adding status field to get clone command --- packages/b2c-cli/src/commands/sandbox/clone/get.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/b2c-cli/src/commands/sandbox/clone/get.ts b/packages/b2c-cli/src/commands/sandbox/clone/get.ts index 4ec34dab..2260875c 100644 --- a/packages/b2c-cli/src/commands/sandbox/clone/get.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/get.ts @@ -78,6 +78,7 @@ export default class CloneGet extends OdsCommand { ['Source Instance', clone?.sourceInstance], ['Target Instance', clone?.targetInstance], ['Realm', clone?.realm], + ['Status', clone?.status], ['Progress', clone?.progressPercentage === undefined ? '-' : `${clone.progressPercentage}%`], ['Created At', clone?.createdAt ? new Date(clone.createdAt).toLocaleString() : undefined], ['Created By', clone?.createdBy], From 2b18210dd7da5922d55b6c6ecccac1d3a71693fd Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Tue, 3 Mar 2026 20:24:22 +0530 Subject: [PATCH 6/8] modified the clone command outputs --- docs/cli/sandbox.md | 83 +++++-- .../src/commands/sandbox/clone/create.ts | 50 +--- .../b2c-cli/src/commands/sandbox/clone/get.ts | 9 +- .../src/commands/sandbox/clone/list.ts | 37 +-- packages/b2c-cli/src/commands/sandbox/get.ts | 57 ++++- packages/b2c-cli/src/commands/sandbox/list.ts | 6 +- .../commands/sandbox/clone/create.test.ts | 142 ++--------- .../test/commands/sandbox/clone/get.test.ts | 31 ++- .../test/commands/sandbox/clone/list.test.ts | 33 ++- .../b2c-cli/test/commands/sandbox/get.test.ts | 233 ++++++++++++++++++ .../test/commands/sandbox/list.test.ts | 19 +- .../b2c-tooling-sdk/specs/ods-api-v1.json | 27 ++ .../src/clients/ods.generated.ts | 11 +- .../b2c-tooling-sdk/src/plugins/loader.ts | 2 +- 14 files changed, 508 insertions(+), 232 deletions(-) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 7f67774f..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 @@ -698,9 +722,9 @@ b2c sandbox clone list #### Available Columns -`cloneId`, `status`, `targetInstance`, `targetProfile`, `progressPercentage`, `createdAt`, `createdBy`, `lastUpdated`, `elapsedTimeInSec`, `sourceInstance`, `realm` +`cloneId`, `sourceInstance`, `targetInstance`, `status`, `progressPercentage`, `createdAt`, `lastUpdated`, `elapsedTimeInSec`, `customCodeVersion` -**Default columns:** `cloneId`, `status`, `targetInstance`, `progressPercentage`, `createdAt` +**Default columns:** `cloneId`, `sourceInstance`, `targetInstance`, `status`, `progressPercentage`, `createdAt` #### Examples @@ -727,12 +751,14 @@ b2c sandbox clone list zzzv-123 --json #### Output ``` -Clone ID Status Target Instance Progress % Created At -──────────────────────────────────────────────────────────────────────────────── -aaaa-001-1642780893121 COMPLETED aaaa-001 100% 2/27/2025, 10:00:00 AM -aaaa-002-1642780893122 IN_PROGRESS aaaa-002 75% 2/27/2025, 11:00:00 AM +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. @@ -753,7 +779,7 @@ b2c sandbox clone create | Flag | Description | Default | |------|-------------|---------| -| `--target-profile` | Resource profile for the cloned sandbox (`medium`, `large`, `xlarge`, `xxlarge`). If not specified, uses the source sandbox's profile. | Source sandbox profile | +| `--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 | | @@ -763,17 +789,17 @@ b2c sandbox clone create # Create a clone with same profile as source sandbox b2c sandbox clone create zzzv-123 -# Create a clone with custom TTL (still uses source profile) +# Create a clone with custom TTL (uses source profile) b2c sandbox clone create zzzv-123 --ttl 48 -# Create a clone with different profile +# 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 --target-profile medium --emails dev@example.com,qa@example.com +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 @@ -797,7 +823,7 @@ To check the clone status, run: - **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 -- If `--target-profile` is not specified, the clone will use the same resource profile as the source sandbox +- 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 @@ -830,21 +856,28 @@ b2c sandbox clone get zzzv-123 aaaa-002-1642780893121 --json #### Output -Displays essential clone information in a formatted table: +Displays comprehensive clone information in a formatted table: ``` Clone Details ────────────────────────────────────────────────── -Clone ID: aaaa-002-1642780893121 -Source Instance: aaaa-000 -Target Instance: aaaa-002 -Realm: aaaa -Progress: 75% -Created At: 2/27/2025, 10:00:00 AM -Created By: user@example.com +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 detailed information including status, timing, filesystem usage, and other metadata, use the `--json` flag. +For the complete response including all metadata, use the `--json` flag. #### Clone Status Values diff --git a/packages/b2c-cli/src/commands/sandbox/clone/create.ts b/packages/b2c-cli/src/commands/sandbox/clone/create.ts index 36eb7321..60f9643e 100644 --- a/packages/b2c-cli/src/commands/sandbox/clone/create.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/create.ts @@ -53,7 +53,7 @@ export default class CloneCreate extends OdsCommand { async run(): Promise<{cloneId?: string}> { const {sandboxId: rawSandboxId} = this.args; - const {'target-profile': targetProfileFlag, emails, ttl} = this.flags; + const {'target-profile': targetProfile, emails, ttl} = this.flags; // Validate TTL if (ttl > 0 && ttl < 24) { @@ -69,48 +69,6 @@ export default class CloneCreate extends OdsCommand { // Resolve sandbox ID (handles both UUID and friendly format) const sandboxId = await this.resolveSandboxId(rawSandboxId); - // Determine target profile - fetch from source sandbox if not provided - let targetProfile = targetProfileFlag; - - if (!targetProfile) { - this.log(t('commands.clone.create.fetchingSource', 'Fetching source sandbox profile...')); - - const sourceResult = await this.odsClient.GET('/sandboxes/{sandboxId}', { - params: {path: {sandboxId}}, - }); - - if (!sourceResult.data?.data?.resourceProfile) { - this.error( - t( - 'commands.clone.create.noSourceProfile', - 'Unable to determine source sandbox profile. Please specify --target-profile explicitly.', - ), - ); - } - - targetProfile = sourceResult.data.data.resourceProfile; - - if (!this.jsonEnabled()) { - this.log( - t('commands.clone.create.usingSourceProfile', 'Using source sandbox profile: {{profile}}', { - profile: targetProfile, - }), - ); - } - } - - // Validate that profile is one of the allowed values - const validProfiles = ['medium', 'large', 'xlarge', 'xxlarge']; - if (!validProfiles.includes(targetProfile)) { - this.error( - t( - 'commands.clone.create.invalidProfile', - 'Invalid target profile "{{profile}}". Must be one of: {{validProfiles}}', - {profile: targetProfile, validProfiles: validProfiles.join(', ')}, - ), - ); - } - this.log(t('commands.clone.create.creating', 'Creating sandbox clone...')); // Prepare request body @@ -119,10 +77,14 @@ export default class CloneCreate extends OdsCommand { emails?: string[]; ttl: number; } = { - targetProfile: targetProfile as 'large' | 'medium' | 'xlarge' | 'xxlarge', 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())); } diff --git a/packages/b2c-cli/src/commands/sandbox/clone/get.ts b/packages/b2c-cli/src/commands/sandbox/clone/get.ts index 2260875c..534139d2 100644 --- a/packages/b2c-cli/src/commands/sandbox/clone/get.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/get.ts @@ -76,17 +76,22 @@ export default class CloneGet extends OdsCommand { const fields: [string, string | undefined][] = [ ['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], - ['Created By', clone?.createdBy], + ['Custom Code Version', clone?.customCodeVersion], + ['Storefront Count', clone?.storefrontCount?.toString()], + ['Filesystem Usage Size', clone?.filesystemUsageSize?.toString()], + ['Database Transfer Size', clone?.databaseTransferSize?.toString()], ]; 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: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); } } diff --git a/packages/b2c-cli/src/commands/sandbox/clone/list.ts b/packages/b2c-cli/src/commands/sandbox/clone/list.ts index 0da73904..9f6697a5 100644 --- a/packages/b2c-cli/src/commands/sandbox/clone/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/list.ts @@ -10,15 +10,11 @@ import {t} from '../../../i18n/index.js'; type SandboxCloneGetModel = OdsComponents['schemas']['SandboxCloneGetModel']; -const COLUMNS: Record> = { +export const COLUMNS: Record> = { cloneId: { header: 'Clone ID', get: (c) => c.cloneId || '-', }, - status: { - header: 'Status', - get: (c) => c.status || '-', - }, sourceInstance: { header: 'Source Instance', get: (c) => c.sourceInstance || '-', @@ -27,9 +23,9 @@ const COLUMNS: Record> = { header: 'Target Instance', get: (c) => c.targetInstance || '-', }, - targetProfile: { - header: 'Profile', - get: (c) => c.targetProfile || '-', + status: { + header: 'Status', + get: (c) => c.status || '-', }, progressPercentage: { header: 'Progress %', @@ -37,11 +33,18 @@ const COLUMNS: Record> = { }, createdAt: { header: 'Created At', - get: (c) => (c.createdAt ? new Date(c.createdAt).toLocaleString() : '-'), - }, - createdBy: { - header: 'Created By', - get: (c) => c.createdBy || '-', + 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', @@ -51,13 +54,13 @@ const COLUMNS: Record> = { header: 'Elapsed Time (sec)', get: (c) => (c.elapsedTimeInSec === undefined ? '-' : c.elapsedTimeInSec.toString()), }, - realm: { - header: 'Realm', - get: (c) => c.realm || '-', + customCodeVersion: { + header: 'Custom Code Version', + get: (c) => c.customCodeVersion || '-', }, }; -const DEFAULT_COLUMNS = ['cloneId', 'status', 'sourceInstance', 'targetInstance', 'progressPercentage', 'createdAt']; +const DEFAULT_COLUMNS = ['cloneId', 'sourceInstance', 'targetInstance', 'status', 'progressPercentage', 'createdAt']; /** * Command to list sandbox clones for a specific sandbox. diff --git a/packages/b2c-cli/src/commands/sandbox/get.ts b/packages/b2c-cli/src/commands/sandbox/get.ts index e8d695aa..61575c11 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( @@ -108,6 +120,39 @@ export default class SandboxGet extends OdsCommand { ); } + // Clone Details (if sandbox was cloned) + if (sandbox.clonedFrom || sandbox.sourceInstanceIdentifier || sandbox.cloneDetails) { + 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: [string, string | undefined][] = [ + ['Cloned From', sandbox.clonedFrom], + ['Source Instance ID', sandbox.sourceInstanceIdentifier], + ]; + + // If cloneDetails is present (from expand=clonedetails), show additional information + 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()], + ); + } + + for (const [label, value] of cloneFields) { + if (value) { + ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + } + // Links if (sandbox.links) { ui.div({text: '', padding: [0, 0, 0, 0]}); 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 index ce643d6d..eaff56e5 100644 --- a/packages/b2c-cli/test/commands/sandbox/clone/create.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/clone/create.test.ts @@ -40,20 +40,6 @@ function stubOdsClientPost(command: any, handler: (path: string, options?: any) }); } -function stubOdsClient( - command: any, - postHandler: (path: string, options?: any) => Promise, - getHandler: (path: string, options?: any) => Promise, -): void { - Object.defineProperty(command, 'odsClient', { - value: { - POST: postHandler, - GET: getHandler, - }, - configurable: true, - }); -} - function stubResolveSandboxId(command: any, handler: (id: string) => Promise): void { command.resolveSandboxId = handler; } @@ -166,7 +152,7 @@ describe('sandbox clone create', () => { }); describe('target profile defaulting', () => { - it('should use source sandbox profile when not specified', async () => { + 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 @@ -175,36 +161,20 @@ describe('sandbox clone create', () => { stubResolveSandboxId(command, async (id) => id); let capturedBody: any; - let getCallCount = 0; - - stubOdsClient( - command, - async (path: string, options?: any) => { - capturedBody = options?.body; - return { - data: {data: {cloneId: 'test-clone-id'}}, - response: new Response(), - }; - }, - async (path: string) => { - getCallCount++; - expect(path).to.equal('/sandboxes/{sandboxId}'); - return { - data: { - data: { - id: 'test-sandbox-id', - resourceProfile: 'large', - }, - }, - response: new Response(), - }; - }, - ); + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); await command.run(); - expect(getCallCount).to.equal(1); - expect(capturedBody.targetProfile).to.equal('large'); + // 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 () => { @@ -216,91 +186,19 @@ describe('sandbox clone create', () => { stubResolveSandboxId(command, async (id) => id); let capturedBody: any; - let getCallCount = 0; - - stubOdsClient( - command, - async (path: string, options?: any) => { - capturedBody = options?.body; - return { - data: {data: {cloneId: 'test-clone-id'}}, - response: new Response(), - }; - }, - async () => { - getCallCount++; - return {data: null, response: new Response()}; - }, - ); + + stubOdsClientPost(command, async (path: string, options?: any) => { + capturedBody = options?.body; + return { + data: {data: {cloneId: 'test-clone-id'}}, + response: new Response(), + }; + }); await command.run(); - // Should NOT call GET when profile is explicitly provided - expect(getCallCount).to.equal(0); expect(capturedBody.targetProfile).to.equal('medium'); }); - - it('should error when source sandbox has no profile', async () => { - const command = new CloneCreate(['test-sandbox-id'], {} as any); - (command as any).args = {sandboxId: 'test-sandbox-id'}; - (command as any).flags = {ttl: 24}; - stubCommandConfigAndLogger(command); - makeCommandThrowOnError(command); - stubResolveSandboxId(command, async (id) => id); - - stubOdsClient( - command, - async () => { - return {data: null, response: new Response()}; - }, - async () => { - return { - data: {data: {}}, // No resourceProfile - response: new Response(), - }; - }, - ); - - try { - await command.run(); - expect.fail('Should have thrown'); - } catch (error: unknown) { - expect((error as Error).message).to.include('Unable to determine source sandbox profile'); - } - }); - - it('should validate profile is allowed value', async () => { - const command = new CloneCreate(['test-sandbox-id'], {} as any); - (command as any).args = {sandboxId: 'test-sandbox-id'}; - (command as any).flags = {ttl: 24}; - stubCommandConfigAndLogger(command); - makeCommandThrowOnError(command); - stubResolveSandboxId(command, async (id) => id); - - stubOdsClient( - command, - async () => { - return {data: null, response: new Response()}; - }, - async () => { - return { - data: { - data: { - resourceProfile: 'invalid-profile', - }, - }, - response: new Response(), - }; - }, - ); - - try { - await command.run(); - expect.fail('Should have thrown'); - } catch (error: unknown) { - expect((error as Error).message).to.include('Invalid target profile'); - } - }); }); describe('output formatting', () => { diff --git a/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts index 0c766924..499de422 100644 --- a/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/clone/get.test.ts @@ -95,7 +95,9 @@ describe('sandbox clone get', () => { 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, @@ -145,7 +147,9 @@ describe('sandbox clone get', () => { 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, @@ -173,12 +177,14 @@ describe('sandbox clone get', () => { 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'); - expect(combinedOutput).to.include('Created By:'); - expect(combinedOutput).to.include('test@example.com'); }); it('should not display additional info in non-JSON mode', async () => { @@ -202,7 +208,9 @@ describe('sandbox clone get', () => { 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', @@ -236,15 +244,16 @@ describe('sandbox clone get', () => { expect(combinedOutput).to.include('aaaa'); expect(combinedOutput).to.include('Progress:'); expect(combinedOutput).to.include('100%'); - expect(combinedOutput).to.include('Created By:'); - expect(combinedOutput).to.include('test@example.com'); - - // Should NOT display additional fields in non-JSON mode - expect(combinedOutput).to.not.include('Last Known State'); - expect(combinedOutput).to.not.include('Custom Code Version'); - expect(combinedOutput).to.not.include('Storefront Count'); - expect(combinedOutput).to.not.include('Filesystem Usage'); - expect(combinedOutput).to.not.include('Database Transfer Size'); + + // 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 () => { diff --git a/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts b/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts index 4db5b353..1d816c38 100644 --- a/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/clone/list.test.ts @@ -8,7 +8,7 @@ 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 from '../../../../src/commands/sandbox/clone/list.js'; +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 { @@ -81,6 +81,37 @@ describe('sandbox clone list', () => { }); }); + 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); 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 e685e78b..3a2cd364 100644 --- a/packages/b2c-tooling-sdk/specs/ods-api-v1.json +++ b/packages/b2c-tooling-sdk/specs/ods-api-v1.json @@ -769,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).", @@ -3089,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 4d0adcb2..3eaf6f1a 100644 --- a/packages/b2c-tooling-sdk/src/clients/ods.generated.ts +++ b/packages/b2c-tooling-sdk/src/clients/ods.generated.ts @@ -922,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 */ @@ -1946,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. */ 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>; From bde204c48da9c8a6ed3ce7e6631181a582d084ef Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Tue, 3 Mar 2026 21:18:34 +0530 Subject: [PATCH 7/8] minor refactors --- .../b2c-cli/src/commands/sandbox/clone/get.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/b2c-cli/src/commands/sandbox/clone/get.ts b/packages/b2c-cli/src/commands/sandbox/clone/get.ts index 534139d2..27df1ead 100644 --- a/packages/b2c-cli/src/commands/sandbox/clone/get.ts +++ b/packages/b2c-cli/src/commands/sandbox/clone/get.ts @@ -67,13 +67,8 @@ export default class CloneGet extends OdsCommand { return {data: clone}; } - 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: [string, string | undefined][] = [ + private buildCloneFields(clone: SandboxCloneGetModel | undefined): [string, string | undefined][] { + return [ ['Clone ID', clone?.cloneId], ['Source Instance', clone?.sourceInstance], ['Source Instance ID', clone?.sourceInstanceId], @@ -88,6 +83,15 @@ export default class CloneGet extends OdsCommand { ['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) { From a73bae264356a3e2128096ad5c68df7ffdf000f9 Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Wed, 4 Mar 2026 15:27:09 +0530 Subject: [PATCH 8/8] removing lint warning --- packages/b2c-cli/src/commands/sandbox/get.ts | 140 ++++++++++--------- 1 file changed, 76 insertions(+), 64 deletions(-) diff --git a/packages/b2c-cli/src/commands/sandbox/get.ts b/packages/b2c-cli/src/commands/sandbox/get.ts index 61575c11..da8738f1 100644 --- a/packages/b2c-cli/src/commands/sandbox/get.ts +++ b/packages/b2c-cli/src/commands/sandbox/get.ts @@ -79,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], @@ -100,80 +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]}); } } + } + + private printLinksSection(ui: ReturnType, sandbox: SandboxModel): void { + if (!sandbox.links) return; - // Tags + 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]}, ); } - - // Clone Details (if sandbox was cloned) - if (sandbox.clonedFrom || sandbox.sourceInstanceIdentifier || sandbox.cloneDetails) { - 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: [string, string | undefined][] = [ - ['Cloned From', sandbox.clonedFrom], - ['Source Instance ID', sandbox.sourceInstanceIdentifier], - ]; - - // If cloneDetails is present (from expand=clonedetails), show additional information - 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()], - ); - } - - for (const [label, value] of cloneFields) { - if (value) { - ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, 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()); } }