diff --git a/.changeset/friendly-sandbox-id.md b/.changeset/friendly-sandbox-id.md new file mode 100644 index 00000000..b34d16b7 --- /dev/null +++ b/.changeset/friendly-sandbox-id.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add support for realm-instance format in ODS commands. You can now use `zzzv-123` or `zzzv_123` instead of full UUIDs for `ods get`, `ods start`, `ods stop`, `ods restart`, and `ods delete` commands. diff --git a/docs/cli/ods.md b/docs/cli/ods.md index fc005f65..5fdb76c9 100644 --- a/docs/cli/ods.md +++ b/docs/cli/ods.md @@ -6,6 +6,23 @@ description: Commands for creating, managing, starting, stopping, and deleting O Commands for managing On-Demand Sandboxes (ODS). +## Sandbox ID Formats + +Commands that operate on a specific sandbox (`get`, `start`, `stop`, `restart`, `delete`) accept two ID formats: + +| Format | Example | Description | +|--------|---------|-------------| +| UUID | `abc12345-1234-1234-1234-abc123456789` | Full sandbox UUID | +| Realm-instance | `zzzv-123` or `zzzv_123` | Realm-instance format | + +The realm-instance format uses the 4-character realm code followed by a dash (`-`) or underscore (`_`) and the instance identifier. When using the realm-instance format, the CLI automatically looks up the corresponding sandbox UUID. + +```bash +# These are equivalent (assuming zzzv-123 resolves to the UUID) +b2c ods get abc12345-1234-1234-1234-abc123456789 +b2c ods get zzzv-123 +``` + ## Global ODS Flags These flags are available on all ODS commands: @@ -176,16 +193,19 @@ b2c ods get | Argument | Description | Required | |----------|-------------|----------| -| `SANDBOXID` | Sandbox ID (UUID) | Yes | +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | ### Examples ```bash -# Get sandbox details +# Get sandbox details using UUID b2c ods get abc12345-1234-1234-1234-abc123456789 +# Get sandbox details using realm-instance format +b2c ods get zzzv-123 + # Output as JSON -b2c ods get abc12345-1234-1234-1234-abc123456789 --json +b2c ods get zzzv_123 --json ``` ### Output @@ -244,16 +264,19 @@ b2c ods start | Argument | Description | Required | |----------|-------------|----------| -| `SANDBOXID` | Sandbox ID (UUID) | Yes | +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | ### Examples ```bash -# Start a sandbox +# Start a sandbox using UUID b2c ods start abc12345-1234-1234-1234-abc123456789 +# Start a sandbox using realm-instance format +b2c ods start zzzv-123 + # Output as JSON -b2c ods start abc12345-1234-1234-1234-abc123456789 --json +b2c ods start zzzv_123 --json ``` --- @@ -272,16 +295,19 @@ b2c ods stop | Argument | Description | Required | |----------|-------------|----------| -| `SANDBOXID` | Sandbox ID (UUID) | Yes | +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | ### Examples ```bash -# Stop a sandbox +# Stop a sandbox using UUID b2c ods stop abc12345-1234-1234-1234-abc123456789 +# Stop a sandbox using realm-instance format +b2c ods stop zzzv-123 + # Output as JSON -b2c ods stop abc12345-1234-1234-1234-abc123456789 --json +b2c ods stop zzzv_123 --json ``` --- @@ -300,16 +326,19 @@ b2c ods restart | Argument | Description | Required | |----------|-------------|----------| -| `SANDBOXID` | Sandbox ID (UUID) | Yes | +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | ### Examples ```bash -# Restart a sandbox +# Restart a sandbox using UUID b2c ods restart abc12345-1234-1234-1234-abc123456789 +# Restart a sandbox using realm-instance format +b2c ods restart zzzv-123 + # Output as JSON -b2c ods restart abc12345-1234-1234-1234-abc123456789 --json +b2c ods restart zzzv_123 --json ``` --- @@ -328,7 +357,7 @@ b2c ods delete | Argument | Description | Required | |----------|-------------|----------| -| `SANDBOXID` | Sandbox ID (UUID) | Yes | +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | ### Flags @@ -339,11 +368,14 @@ b2c ods delete ### Examples ```bash -# Delete a sandbox (with confirmation prompt) +# Delete a sandbox using UUID (with confirmation prompt) b2c ods delete abc12345-1234-1234-1234-abc123456789 +# Delete a sandbox using realm-instance format +b2c ods delete zzzv-123 + # Delete without confirmation -b2c ods delete abc12345-1234-1234-1234-abc123456789 --force +b2c ods delete zzzv_123 --force ``` ### Notes diff --git a/packages/b2c-cli/src/commands/ods/delete.ts b/packages/b2c-cli/src/commands/ods/delete.ts index f2684a18..8fff9a0a 100644 --- a/packages/b2c-cli/src/commands/ods/delete.ts +++ b/packages/b2c-cli/src/commands/ods/delete.ts @@ -32,7 +32,7 @@ async function confirm(message: string): Promise { export default class OdsDelete extends OdsCommand { static args = { sandboxId: Args.string({ - description: 'Sandbox ID (UUID)', + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', required: true, }), }; @@ -44,7 +44,8 @@ export default class OdsDelete extends OdsCommand { static examples = [ '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', - '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --force', + '<%= config.bin %> <%= command.id %> zzzv-123', + '<%= config.bin %> <%= command.id %> zzzv_123 --force', ]; static flags = { @@ -56,7 +57,7 @@ export default class OdsDelete extends OdsCommand { }; async run(): Promise { - const sandboxId = this.args.sandboxId; + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); // Get sandbox details first to show in confirmation const getResult = await this.odsClient.GET('/sandboxes/{sandboxId}', { diff --git a/packages/b2c-cli/src/commands/ods/get.ts b/packages/b2c-cli/src/commands/ods/get.ts index 601992c6..a1c9a3bc 100644 --- a/packages/b2c-cli/src/commands/ods/get.ts +++ b/packages/b2c-cli/src/commands/ods/get.ts @@ -17,7 +17,7 @@ type SandboxModel = OdsComponents['schemas']['SandboxModel']; export default class OdsGet extends OdsCommand { static args = { sandboxId: Args.string({ - description: 'Sandbox ID (UUID)', + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', required: true, }), }; @@ -31,11 +31,12 @@ export default class OdsGet extends OdsCommand { static examples = [ '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', - '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json', + '<%= config.bin %> <%= command.id %> zzzv-123', + '<%= config.bin %> <%= command.id %> zzzv_123 --json', ]; async run(): Promise { - const sandboxId = this.args.sandboxId; + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); this.log(t('commands.ods.get.fetching', 'Fetching sandbox {{sandboxId}}...', {sandboxId})); diff --git a/packages/b2c-cli/src/commands/ods/restart.ts b/packages/b2c-cli/src/commands/ods/restart.ts index 6f1d080c..372a49cd 100644 --- a/packages/b2c-cli/src/commands/ods/restart.ts +++ b/packages/b2c-cli/src/commands/ods/restart.ts @@ -16,7 +16,7 @@ type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; export default class OdsRestart extends OdsCommand { static args = { sandboxId: Args.string({ - description: 'Sandbox ID (UUID)', + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', required: true, }), }; @@ -30,11 +30,12 @@ export default class OdsRestart extends OdsCommand { static examples = [ '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', - '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json', + '<%= config.bin %> <%= command.id %> zzzv-123', + '<%= config.bin %> <%= command.id %> zzzv_123 --json', ]; async run(): Promise { - const sandboxId = this.args.sandboxId; + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); this.log(t('commands.ods.restart.restarting', 'Restarting sandbox {{sandboxId}}...', {sandboxId})); diff --git a/packages/b2c-cli/src/commands/ods/start.ts b/packages/b2c-cli/src/commands/ods/start.ts index b89109e8..11fa74e3 100644 --- a/packages/b2c-cli/src/commands/ods/start.ts +++ b/packages/b2c-cli/src/commands/ods/start.ts @@ -16,7 +16,7 @@ type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; export default class OdsStart extends OdsCommand { static args = { sandboxId: Args.string({ - description: 'Sandbox ID (UUID)', + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', required: true, }), }; @@ -30,11 +30,12 @@ export default class OdsStart extends OdsCommand { static examples = [ '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', - '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json', + '<%= config.bin %> <%= command.id %> zzzv-123', + '<%= config.bin %> <%= command.id %> zzzv_123 --json', ]; async run(): Promise { - const sandboxId = this.args.sandboxId; + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); this.log(t('commands.ods.start.starting', 'Starting sandbox {{sandboxId}}...', {sandboxId})); diff --git a/packages/b2c-cli/src/commands/ods/stop.ts b/packages/b2c-cli/src/commands/ods/stop.ts index b8f1a26d..de2d7eba 100644 --- a/packages/b2c-cli/src/commands/ods/stop.ts +++ b/packages/b2c-cli/src/commands/ods/stop.ts @@ -16,7 +16,7 @@ type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; export default class OdsStop extends OdsCommand { static args = { sandboxId: Args.string({ - description: 'Sandbox ID (UUID)', + description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)', required: true, }), }; @@ -30,11 +30,12 @@ export default class OdsStop extends OdsCommand { static examples = [ '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789', - '<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json', + '<%= config.bin %> <%= command.id %> zzzv-123', + '<%= config.bin %> <%= command.id %> zzzv_123 --json', ]; async run(): Promise { - const sandboxId = this.args.sandboxId; + const sandboxId = await this.resolveSandboxId(this.args.sandboxId); this.log(t('commands.ods.stop.stopping', 'Stopping sandbox {{sandboxId}}...', {sandboxId})); diff --git a/packages/b2c-tooling-sdk/src/cli/ods-command.ts b/packages/b2c-tooling-sdk/src/cli/ods-command.ts index 898a740b..82c34390 100644 --- a/packages/b2c-tooling-sdk/src/cli/ods-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/ods-command.ts @@ -7,6 +7,7 @@ import {Command, Flags} from '@oclif/core'; import {OAuthCommand} from './oauth-command.js'; import {createOdsClient, type OdsClient} from '../clients/ods.js'; import {DEFAULT_ODS_HOST} from '../defaults.js'; +import {isUuid, parseFriendlySandboxId, SandboxNotFoundError} from '../operations/ods/sandbox-lookup.js'; /** * Base command for ODS (On-Demand Sandbox) operations. @@ -82,4 +83,62 @@ export abstract class OdsCommand extends OAuthCommand< protected get odsHost(): string { return this.flags['sandbox-api-host'] ?? DEFAULT_ODS_HOST; } + + /** + * Resolves a sandbox identifier to a UUID. + * + * Supports both UUID format and friendly format (realm-instance, e.g., "abcd-123" or "abcd_123"). + * If given a UUID, returns it directly. If given a friendly format, queries the API to find + * the matching sandbox and logs the resolution. + * + * @param identifier - Sandbox identifier (UUID or friendly format) + * @returns The sandbox UUID + * @throws Error if the sandbox cannot be found (friendly ID not resolved) + * + * @example + * ```typescript + * // In a command's run() method: + * const sandboxId = await this.resolveSandboxId(this.args.sandboxId); + * ``` + */ + protected async resolveSandboxId(identifier: string): Promise { + // If already a UUID, return directly + if (isUuid(identifier)) { + return identifier; + } + + // Try to parse as friendly ID + const parsed = parseFriendlySandboxId(identifier); + if (!parsed) { + // Not a UUID and not a friendly ID - pass through as-is + // (let the API return an appropriate error) + return identifier; + } + + // Log that we're looking up the sandbox + this.log(`Looking up sandbox ${identifier}...`); + + // Query sandboxes filtered by realm + const {data, error} = await this.odsClient.GET('/sandboxes', { + params: { + query: { + filter_params: `realm=${parsed.realm}`, + }, + }, + }); + + if (error || !data?.data) { + this.error(new SandboxNotFoundError(identifier, parsed.realm, parsed.instance).message); + } + + // Find sandbox with matching instance + const sandbox = data.data.find((s) => s.instance?.toLowerCase() === parsed.instance); + + if (!sandbox?.id) { + this.error(new SandboxNotFoundError(identifier, parsed.realm, parsed.instance).message); + } + + this.log(`Resolved ${identifier} to sandbox ${sandbox.id}`); + return sandbox.id; + } } diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index a39f5974..1ec1ec02 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -204,6 +204,15 @@ export type { DownloadDocsResult, } from './operations/docs/index.js'; +// Operations - ODS +export { + isUuid, + isFriendlySandboxId, + parseFriendlySandboxId, + resolveSandboxId, + SandboxNotFoundError, +} from './operations/ods/index.js'; + // Defaults export {DEFAULT_ACCOUNT_MANAGER_HOST, DEFAULT_ODS_HOST} from './defaults.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/ods/index.ts b/packages/b2c-tooling-sdk/src/operations/ods/index.ts new file mode 100644 index 00000000..d98ab5a2 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/ods/index.ts @@ -0,0 +1,18 @@ +/* + * 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 + */ +/** + * ODS (On-Demand Sandbox) operations. + * + * @module operations/ods + */ + +export { + isUuid, + isFriendlySandboxId, + parseFriendlySandboxId, + resolveSandboxId, + SandboxNotFoundError, +} from './sandbox-lookup.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/ods/sandbox-lookup.ts b/packages/b2c-tooling-sdk/src/operations/ods/sandbox-lookup.ts new file mode 100644 index 00000000..895391fb --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/ods/sandbox-lookup.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Sandbox ID lookup utilities for resolving friendly sandbox identifiers. + * + * @module operations/ods/sandbox-lookup + */ +import type {OdsClient} from '../../clients/ods.js'; + +/** + * UUID regex pattern (standard 8-4-4-4-12 format). + */ +const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; + +/** + * Friendly sandbox ID pattern: realm-instance or realm_instance + * - realm: 4 alphanumeric characters + * - separator: dash or underscore + * - instance: 1+ alphanumeric characters + */ +const FRIENDLY_ID_REGEX = /^([a-z\d]{4})[-_]([a-z\d]+)$/i; + +/** + * Prefix used in organization ID format (e.g., f_ecom_zzpq_013). + */ +const ECOM_PREFIX = 'f_ecom_'; + +/** + * Strips the f_ecom_ prefix if present. + */ +function normalizeIdentifier(value: string): string { + if (value.toLowerCase().startsWith(ECOM_PREFIX)) { + return value.slice(ECOM_PREFIX.length); + } + return value; +} + +/** + * Error thrown when a sandbox cannot be found by its friendly identifier. + */ +export class SandboxNotFoundError extends Error { + constructor( + public readonly identifier: string, + public readonly realm?: string, + public readonly instance?: string, + ) { + const message = + realm && instance + ? `Sandbox not found: ${identifier} (realm=${realm}, instance=${instance})` + : `Sandbox not found: ${identifier}`; + super(message); + this.name = 'SandboxNotFoundError'; + } +} + +/** + * Checks if a string is a valid UUID. + * + * @param value - The string to check + * @returns true if the value is a valid UUID + */ +export function isUuid(value: string): boolean { + return UUID_REGEX.test(value); +} + +/** + * Checks if a string matches the friendly sandbox ID format (realm-instance or realm_instance). + * + * @param value - The string to check + * @returns true if the value matches the friendly format + */ +export function isFriendlySandboxId(value: string): boolean { + return FRIENDLY_ID_REGEX.test(normalizeIdentifier(value)); +} + +/** + * Parses a friendly sandbox ID into its realm and instance components. + * + * @param value - The friendly ID to parse (e.g., "abcd-123" or "abcd_123") + * @returns Object with realm and instance, or null if not a valid friendly ID + */ +export function parseFriendlySandboxId(value: string): {realm: string; instance: string} | null { + const normalized = normalizeIdentifier(value); + const match = normalized.match(FRIENDLY_ID_REGEX); + if (!match) { + return null; + } + return { + realm: match[1].toLowerCase(), + instance: match[2].toLowerCase(), + }; +} + +/** + * Resolves a sandbox identifier to a UUID. + * + * If the identifier is already a UUID, it is returned directly without making an API call. + * If the identifier is a friendly format (realm-instance), it queries the ODS API to find + * the matching sandbox and returns its UUID. + * + * @param client - The ODS API client + * @param identifier - Sandbox identifier (UUID or friendly format like "abcd-123") + * @returns The sandbox UUID + * @throws {SandboxNotFoundError} If the sandbox cannot be found + * + * @example + * ```typescript + * // UUID is returned directly + * const uuid = await resolveSandboxId(client, 'abc12345-1234-1234-1234-abc123456789'); + * // => 'abc12345-1234-1234-1234-abc123456789' + * + * // Friendly ID is looked up + * const uuid = await resolveSandboxId(client, 'zzzv-123'); + * // => 'abc12345-1234-1234-1234-abc123456789' (actual UUID from API) + * ``` + */ +export async function resolveSandboxId(client: OdsClient, identifier: string): Promise { + // If already a UUID, return directly + if (isUuid(identifier)) { + return identifier; + } + + // Try to parse as friendly ID + const parsed = parseFriendlySandboxId(identifier); + if (!parsed) { + // Not a UUID and not a friendly ID - treat as UUID and let API return 404 + return identifier; + } + + // Query sandboxes filtered by realm + const {data, error} = await client.GET('/sandboxes', { + params: { + query: { + filter_params: `realm=${parsed.realm}`, + }, + }, + }); + + if (error || !data?.data) { + throw new SandboxNotFoundError(identifier, parsed.realm, parsed.instance); + } + + // Find sandbox with matching instance + const sandbox = data.data.find((s) => s.instance?.toLowerCase() === parsed.instance); + + if (!sandbox?.id) { + throw new SandboxNotFoundError(identifier, parsed.realm, parsed.instance); + } + + return sandbox.id; +} diff --git a/packages/b2c-tooling-sdk/test/operations/ods/sandbox-lookup.test.ts b/packages/b2c-tooling-sdk/test/operations/ods/sandbox-lookup.test.ts new file mode 100644 index 00000000..4176c145 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/ods/sandbox-lookup.test.ts @@ -0,0 +1,287 @@ +/* + * 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 {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createOdsClient} from '../../../src/clients/ods.js'; +import { + isUuid, + isFriendlySandboxId, + parseFriendlySandboxId, + resolveSandboxId, + SandboxNotFoundError, +} from '../../../src/operations/ods/sandbox-lookup.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const TEST_HOST = 'admin.test.dx.commercecloud.salesforce.com'; +const BASE_URL = `https://${TEST_HOST}/api/v1`; + +describe('sandbox-lookup', () => { + describe('isUuid', () => { + it('should return true for valid UUIDs', () => { + expect(isUuid('abc12345-1234-1234-1234-abc123456789')).to.be.true; + expect(isUuid('00000000-0000-0000-0000-000000000000')).to.be.true; + expect(isUuid('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')).to.be.true; + expect(isUuid('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).to.be.true; + }); + + it('should return false for invalid UUIDs', () => { + expect(isUuid('not-a-uuid')).to.be.false; + expect(isUuid('abc12345-1234-1234-1234')).to.be.false; + expect(isUuid('abc12345-1234-1234-1234-abc12345678')).to.be.false; + expect(isUuid('abc12345-1234-1234-1234-abc1234567890')).to.be.false; + expect(isUuid('abcd-123')).to.be.false; + expect(isUuid('zzzv_456')).to.be.false; + expect(isUuid('')).to.be.false; + }); + }); + + describe('isFriendlySandboxId', () => { + it('should return true for valid friendly IDs with dash', () => { + expect(isFriendlySandboxId('abcd-123')).to.be.true; + expect(isFriendlySandboxId('zzzv-456')).to.be.true; + expect(isFriendlySandboxId('ABCD-789')).to.be.true; + expect(isFriendlySandboxId('a1b2-c3d')).to.be.true; + }); + + it('should return true for valid friendly IDs with underscore', () => { + expect(isFriendlySandboxId('abcd_123')).to.be.true; + expect(isFriendlySandboxId('zzzv_456')).to.be.true; + expect(isFriendlySandboxId('ABCD_789')).to.be.true; + expect(isFriendlySandboxId('a1b2_c3d')).to.be.true; + }); + + it('should return true for friendly IDs with f_ecom_ prefix', () => { + expect(isFriendlySandboxId('f_ecom_zzpq_013')).to.be.true; + expect(isFriendlySandboxId('f_ecom_abcd_123')).to.be.true; + expect(isFriendlySandboxId('F_ECOM_ZZZV_456')).to.be.true; + }); + + it('should return false for invalid friendly IDs', () => { + expect(isFriendlySandboxId('abc-123')).to.be.false; // realm too short + expect(isFriendlySandboxId('abcde-123')).to.be.false; // realm too long + expect(isFriendlySandboxId('abcd123')).to.be.false; // no separator + expect(isFriendlySandboxId('abcd-')).to.be.false; // no instance + expect(isFriendlySandboxId('-123')).to.be.false; // no realm + expect(isFriendlySandboxId('abc12345-1234-1234-1234-abc123456789')).to.be.false; // UUID + expect(isFriendlySandboxId('')).to.be.false; + }); + }); + + describe('parseFriendlySandboxId', () => { + it('should parse valid friendly IDs with dash', () => { + const result = parseFriendlySandboxId('abcd-123'); + expect(result).to.deep.equal({realm: 'abcd', instance: '123'}); + }); + + it('should parse valid friendly IDs with underscore', () => { + const result = parseFriendlySandboxId('zzzv_456'); + expect(result).to.deep.equal({realm: 'zzzv', instance: '456'}); + }); + + it('should lowercase the realm and instance', () => { + const result = parseFriendlySandboxId('ABCD-XYZ'); + expect(result).to.deep.equal({realm: 'abcd', instance: 'xyz'}); + }); + + it('should strip f_ecom_ prefix and parse', () => { + expect(parseFriendlySandboxId('f_ecom_zzpq_013')).to.deep.equal({realm: 'zzpq', instance: '013'}); + expect(parseFriendlySandboxId('f_ecom_abcd_123')).to.deep.equal({realm: 'abcd', instance: '123'}); + expect(parseFriendlySandboxId('F_ECOM_ZZZV_456')).to.deep.equal({realm: 'zzzv', instance: '456'}); + }); + + it('should return null for invalid formats', () => { + expect(parseFriendlySandboxId('abc-123')).to.be.null; + expect(parseFriendlySandboxId('abcde-123')).to.be.null; + expect(parseFriendlySandboxId('not-valid-format')).to.be.null; + expect(parseFriendlySandboxId('abc12345-1234-1234-1234-abc123456789')).to.be.null; + }); + }); + + describe('resolveSandboxId', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let odsClient: ReturnType; + + beforeEach(() => { + const mockAuth = new MockAuthStrategy(); + odsClient = createOdsClient({host: TEST_HOST}, mockAuth); + }); + + it('should return UUID directly without API call', async () => { + const uuid = 'abc12345-1234-1234-1234-abc123456789'; + // No MSW handler needed - if it makes a request, test will fail + const result = await resolveSandboxId(odsClient, uuid); + expect(result).to.equal(uuid); + }); + + it('should look up sandbox by friendly ID (dash separator)', async () => { + const expectedUuid = 'found-uuid-1234-1234-abc123456789'; + + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('filter_params')).to.equal('realm=zzzv'); + return HttpResponse.json({ + data: [ + {id: expectedUuid, realm: 'zzzv', instance: '123', state: 'started'}, + {id: 'other-uuid', realm: 'zzzv', instance: '456', state: 'stopped'}, + ], + }); + }), + ); + + const result = await resolveSandboxId(odsClient, 'zzzv-123'); + expect(result).to.equal(expectedUuid); + }); + + it('should look up sandbox by friendly ID (underscore separator)', async () => { + const expectedUuid = 'found-uuid-1234-1234-abc123456789'; + + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('filter_params')).to.equal('realm=abcd'); + return HttpResponse.json({ + data: [{id: expectedUuid, realm: 'abcd', instance: '789', state: 'started'}], + }); + }), + ); + + const result = await resolveSandboxId(odsClient, 'abcd_789'); + expect(result).to.equal(expectedUuid); + }); + + it('should be case-insensitive for friendly IDs', async () => { + const expectedUuid = 'found-uuid-1234-1234-abc123456789'; + + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('filter_params')).to.equal('realm=zzzv'); + return HttpResponse.json({ + data: [{id: expectedUuid, realm: 'ZZZV', instance: 'ABC', state: 'started'}], + }); + }), + ); + + const result = await resolveSandboxId(odsClient, 'ZZZV-ABC'); + expect(result).to.equal(expectedUuid); + }); + + it('should look up sandbox by friendly ID with f_ecom_ prefix', async () => { + const expectedUuid = 'found-uuid-1234-1234-abc123456789'; + + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('filter_params')).to.equal('realm=zzpq'); + return HttpResponse.json({ + data: [{id: expectedUuid, realm: 'zzpq', instance: '013', state: 'started'}], + }); + }), + ); + + const result = await resolveSandboxId(odsClient, 'f_ecom_zzpq_013'); + expect(result).to.equal(expectedUuid); + }); + + it('should throw SandboxNotFoundError when sandbox not found', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, () => { + return HttpResponse.json({data: []}); + }), + ); + + try { + await resolveSandboxId(odsClient, 'zzzv-999'); + expect.fail('Should have thrown SandboxNotFoundError'); + } catch (error) { + expect(error).to.be.instanceOf(SandboxNotFoundError); + expect((error as SandboxNotFoundError).identifier).to.equal('zzzv-999'); + expect((error as SandboxNotFoundError).realm).to.equal('zzzv'); + expect((error as SandboxNotFoundError).instance).to.equal('999'); + expect((error as Error).message).to.include('Sandbox not found'); + expect((error as Error).message).to.include('zzzv-999'); + } + }); + + it('should throw SandboxNotFoundError when instance not in realm', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, () => { + return HttpResponse.json({ + data: [{id: 'other-uuid', realm: 'zzzv', instance: '456', state: 'started'}], + }); + }), + ); + + try { + await resolveSandboxId(odsClient, 'zzzv-123'); + expect.fail('Should have thrown SandboxNotFoundError'); + } catch (error) { + expect(error).to.be.instanceOf(SandboxNotFoundError); + } + }); + + it('should throw SandboxNotFoundError on API error', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, () => { + return HttpResponse.json({error: {message: 'Unauthorized'}}, {status: 401}); + }), + ); + + try { + await resolveSandboxId(odsClient, 'zzzv-123'); + expect.fail('Should have thrown SandboxNotFoundError'); + } catch (error) { + expect(error).to.be.instanceOf(SandboxNotFoundError); + } + }); + + it('should pass through invalid format as-is', async () => { + // An identifier that isn't a UUID and isn't a valid friendly format + // is passed through (API will likely return 404) + const result = await resolveSandboxId(odsClient, 'invalid'); + expect(result).to.equal('invalid'); + }); + }); + + describe('SandboxNotFoundError', () => { + it('should include identifier in message', () => { + const error = new SandboxNotFoundError('test-id'); + expect(error.message).to.equal('Sandbox not found: test-id'); + expect(error.identifier).to.equal('test-id'); + expect(error.realm).to.be.undefined; + expect(error.instance).to.be.undefined; + }); + + it('should include realm and instance when provided', () => { + const error = new SandboxNotFoundError('zzzv-123', 'zzzv', '123'); + expect(error.message).to.equal('Sandbox not found: zzzv-123 (realm=zzzv, instance=123)'); + expect(error.identifier).to.equal('zzzv-123'); + expect(error.realm).to.equal('zzzv'); + expect(error.instance).to.equal('123'); + }); + + it('should have correct name', () => { + const error = new SandboxNotFoundError('test-id'); + expect(error.name).to.equal('SandboxNotFoundError'); + }); + }); +}); diff --git a/plugins/b2c-cli/skills/b2c-ods/SKILL.md b/plugins/b2c-cli/skills/b2c-ods/SKILL.md index e31bad94..fb87983a 100644 --- a/plugins/b2c-cli/skills/b2c-ods/SKILL.md +++ b/plugins/b2c-cli/skills/b2c-ods/SKILL.md @@ -7,6 +7,15 @@ description: Create and manage on-demand sandboxes (ODS). Use when provisioning Use the `b2c` CLI plugin to manage Salesforce B2C Commerce On-demand sandboxes (ODS). Only create or delete a sandbox if explicitly asked as this may be a billable or destructible action. +## Sandbox ID Formats + +Commands that operate on a specific sandbox accept two ID formats: + +- **UUID**: The full sandbox UUID (e.g., `abc12345-1234-1234-1234-abc123456789`) +- **Realm-instance**: The realm-instance format (e.g., `zzzv-123` or `zzzv_123`) + +The realm-instance format uses the 4-character realm code followed by a dash or underscore and the instance number. When using a realm-instance format, the CLI will automatically look up the corresponding UUID. + ## Examples ### List ODS Sandboxes @@ -36,6 +45,24 @@ b2c ods create --realm zzpq --profile large b2c ods create --realm zzpq --log-level trace ``` +### Get/Start/Stop/Restart/Delete Sandbox + +Commands that operate on a specific sandbox support both UUID and realm-instance formats: + +```bash +# Using UUID +b2c ods get abc12345-1234-1234-1234-abc123456789 +b2c ods start abc12345-1234-1234-1234-abc123456789 +b2c ods stop abc12345-1234-1234-1234-abc123456789 + +# Using realm-instance format +b2c ods get zzzv-123 +b2c ods start zzzv_123 +b2c ods stop zzzv-123 +b2c ods restart zzzv-123 +b2c ods delete zzzv-123 --force +``` + ### More Commands See `b2c ods --help` for a full list of available commands and options in the `ods` topic.