diff --git a/.gitignore b/.gitignore index 7cda5356..12cdd3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ docs/.vitepress/cache docs/api/ dw.json +dw.json* diff --git a/AGENTS.md b/AGENTS.md index 50b001de..4c8cb757 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,38 @@ +# B2C CLI -- this is a monorepo project; packages: - - ./packages/b2c-cli - the command line interface built with oclif - - ./packages/b2c-tooling-sdk - the SDK/library for B2C Commerce operations; support the CLI and can be used standalone +This is a monorepo project with the following packages: +- `./packages/b2c-cli` - the command line interface built with oclif +- `./packages/b2c-tooling-sdk` - the SDK/library for B2C Commerce operations; supports the CLI and can be used standalone + +## Common Commands + +```bash +# Install dependencies +pnpm install + +# Build all packages +pnpm run build + +# Build specific package +pnpm --filter @salesforce/b2c-cli run build +pnpm --filter @salesforce/b2c-tooling-sdk run build + +# Run tests (includes linting) +pnpm run test + +# Run tests for specific package +pnpm --filter @salesforce/b2c-cli run test +pnpm --filter @salesforce/b2c-tooling-sdk run test + +# Format code with prettier +pnpm run -r format + +# Lint only (without tests) +pnpm run -r lint + +# Run CLI in development mode +./packages/b2c-cli/bin/dev.js +``` ## Setup/Packaging diff --git a/b2c_log_center_stream3.png b/b2c_log_center_stream3.png deleted file mode 100644 index a8fb13b8..00000000 Binary files a/b2c_log_center_stream3.png and /dev/null differ diff --git a/dw.json.bak b/dw.json.bak deleted file mode 120000 index c4b894ec..00000000 --- a/dw.json.bak +++ /dev/null @@ -1 +0,0 @@ -/Users/clavery/code/b2c-commerce-industries/dw.json \ No newline at end of file diff --git a/easy-setup-step-by-step.sh b/easy-setup-step-by-step.sh deleted file mode 100644 index 17db539e..00000000 --- a/easy-setup-step-by-step.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -export SFCC_MRT_PROJECT=cli -export REALM="zzpq" -export SFCC_CLIENT_ID="a0a4deb0-5e03-477b-bfdc-e42ccfae6161" - -b2c ods create -r $REALM --wait --ttl 0 - -# create mrt env called chuck in the cli project -b2c mrt env create chuck --name chuck \ - --allow-cookies \ - --proxy api=kv7kzm78.api.commercecloud.salesforce.com \ - --proxy einstein=api.cquotient.com - -# construct redirect-uri from ssr_external_hostname above and don't use localhost:3000 -b2c slas client create \ - --channels MarketStreet \ - --tenant-id zzpq_014 \ - --short-code kv7kzm78 \ - --redirect-uri https://myproject-chuck.sfdc-8tgtt5-ecom1.exp-delivery.com/callback,http://localhost:3000/callback \ - --default-scopes - -# get clientId and COMMERCE_API_SLAS_SECRET from the output of the previous command and callback from the redirect-uri above -b2c mrt env var set -e chuck \ - PUBLIC__app__commerce__api__clientId=5810be72-3b2f-49bc-8ca1-eb88119de2fa \ - PUBLIC__app__commerce__api__organizationId=f_ecom_zzpq_014 \ - PUBLIC__app__commerce__api__siteId=RefArch \ - PUBLIC__app__commerce__api__shortCode= \ - PUBLIC__app__commerce__api__callback=https://myproject-chuck.sfdc-8tgtt5-ecom1.exp-delivery.com/callback \ - PUBLIC__app__commerce__api__privateKeyEnabled=true \ - COMMERCE_API_SLAS_SECRET=sk_kasdjlkjsalkjasd - -# import market street data from storefront-datasets repo -b2c job import --server zzpq-014.dx.commercecloud.salesforce.com ~/code/storefront-datasets/demo_data_marketstreet - -b2c code deploy -b2c mrt push -e headertest -p cli -b ~/code/SFCC-Odyssey/packages/template-retail-rsc-app/build --ssr-only='ssr.js,ssr.mjs,chunk.mjs,server/**/*' - - diff --git a/easy-setup.sh b/easy-setup.sh deleted file mode 100755 index 0a2b88c1..00000000 --- a/easy-setup.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Configuration - modify these values for your setup -ENV_SLUG="demo15" -ENV_NAME="demo15" -SITE_ID="RefArch" -TENANT_ID="zzpq_013" -ORGANIZATION_ID="f_ecom_zzsa_009" -SHORT_CODE="kv7kzm78" -BUILD_PATH="${BUILD_PATH:-$HOME/code/SFCC-Odyssey/packages/template-retail-rsc-app/build}" - -export SFCC_MRT_PROJECT="${SFCC_MRT_PROJECT:-cli}" - -echo "=== Step 1: Creating MRT environment ===" -ENV_JSON=$(b2c mrt env create "$ENV_SLUG" --name "$ENV_NAME" --json) -echo "Environment created." - -# Extract the external hostname for the callback URL -SSR_EXTERNAL_HOSTNAME=$(echo "$ENV_JSON" | jq -r '.ssr_external_hostname // empty') -if [[ -z "$SSR_EXTERNAL_HOSTNAME" ]]; then - # Fall back to hostname if external hostname not set - SSR_EXTERNAL_HOSTNAME=$(echo "$ENV_JSON" | jq -r '.hostname // empty') -fi - -if [[ -z "$SSR_EXTERNAL_HOSTNAME" ]]; then - echo "Warning: Could not determine external hostname from environment. Using localhost." - CALLBACK_URL="http://localhost:3000/callback" -else - CALLBACK_URL="https://${SSR_EXTERNAL_HOSTNAME}/callback" -fi -echo "Callback URL: $CALLBACK_URL" - -echo "" -echo "=== Step 2: Creating SLAS client ===" -SLAS_JSON=$(b2c slas client create --channels "$SITE_ID" \ - --redirect-uri "$CALLBACK_URL" \ - --default-scopes \ - --tenant-id "$TENANT_ID" \ - --json) -echo "SLAS client created." - -# Extract client ID and secret from SLAS response -CLIENT_ID=$(echo "$SLAS_JSON" | jq -r '.clientId') -CLIENT_SECRET=$(echo "$SLAS_JSON" | jq -r '.secret // empty') - -if [[ -z "$CLIENT_ID" ]]; then - echo "Error: Failed to get client ID from SLAS response" - exit 1 -fi - -echo "Client ID: $CLIENT_ID" -if [[ -n "$CLIENT_SECRET" ]]; then - echo "Client Secret: $CLIENT_SECRET (save this - it won't be shown again)" -fi - -echo "" -echo "=== Step 3: Setting environment variables ===" -b2c mrt env var set -e "$ENV_SLUG" \ - "PUBLIC__app__commerce__api__clientId=$CLIENT_ID" \ - "PUBLIC__app__commerce__api__organizationId=$ORGANIZATION_ID" \ - "PUBLIC__app__commerce__api__siteId=$SITE_ID" \ - "PUBLIC__app__commerce__api__shortCode=$SHORT_CODE" \ - "PUBLIC__app__commerce__api__proxy=/mobify/proxy/api" \ - "PUBLIC__app__commerce__api__callback=$CALLBACK_URL" \ - "PUBLIC__app__commerce__api__privateKeyEnabled=true" \ - ${CLIENT_SECRET:+"COMMERCE_API_SLAS_SECRET=$CLIENT_SECRET"} - -echo "" -echo "=== Step 4: Deploying code ===" -b2c code deploy - -echo "" -echo "=== Step 5: Importing job data ===" -b2c job import data/urls - -echo "" -echo "=== Step 6: Pushing to MRT ===" -b2c mrt push -e "$ENV_SLUG" -b "$BUILD_PATH" - -echo "" -echo "=== Setup Complete ===" -echo "Environment: $ENV_SLUG" -echo "Client ID: $CLIENT_ID" -echo "URL: https://${SSR_EXTERNAL_HOSTNAME:-localhost:3000}" diff --git a/packages/b2c-cli/src/commands/mrt/env/create.ts b/packages/b2c-cli/src/commands/mrt/env/create.ts index 46f47386..011a6bce 100644 --- a/packages/b2c-cli/src/commands/mrt/env/create.ts +++ b/packages/b2c-cli/src/commands/mrt/env/create.ts @@ -6,7 +6,7 @@ import {Args, Flags, ux} from '@oclif/core'; import cliui from 'cliui'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {createEnv, type MrtEnvironment} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {createEnv, waitForEnv, type MrtEnvironment} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t} from '../../../i18n/index.js'; /** @@ -140,18 +140,19 @@ export default class MrtEnvCreate extends MrtCommand { static enableJsonFlag = true; static examples = [ + '<%= config.bin %> <%= command.id %> staging --project my-storefront', '<%= config.bin %> <%= command.id %> staging --project my-storefront --name "Staging Environment"', - '<%= config.bin %> <%= command.id %> production --project my-storefront --name "Production" --production', - '<%= config.bin %> <%= command.id %> feature-test -p my-storefront -n "Feature Test" --region eu-west-1', - '<%= config.bin %> <%= command.id %> staging -p my-storefront -n "Staging" --proxy api=api.example.com --proxy ocapi=ocapi.example.com', + '<%= config.bin %> <%= command.id %> production --project my-storefront --production', + '<%= config.bin %> <%= command.id %> feature-test -p my-storefront --region eu-west-1', + '<%= config.bin %> <%= command.id %> staging -p my-storefront --proxy api=api.example.com --proxy ocapi=ocapi.example.com', + '<%= config.bin %> <%= command.id %> staging -p my-storefront --wait', ]; static flags = { ...MrtCommand.baseFlags, name: Flags.string({ char: 'n', - description: 'Display name for the environment', - required: true, + description: 'Display name for the environment (defaults to slug)', }), region: Flags.string({ char: 'r', @@ -185,6 +186,11 @@ export default class MrtEnvCreate extends MrtCommand { description: 'Proxy configuration in format path=host (can be specified multiple times)', multiple: true, }), + wait: Flags.boolean({ + char: 'w', + description: 'Wait for the environment to be ready before returning', + default: false, + }), }; async run(): Promise { @@ -200,7 +206,7 @@ export default class MrtEnvCreate extends MrtCommand { } const { - name, + name: nameFlag, region, production: isProduction, hostname, @@ -209,8 +215,12 @@ export default class MrtEnvCreate extends MrtCommand { 'allow-cookies': allowCookies, 'enable-source-maps': enableSourceMaps, proxy: proxyStrings, + wait, } = this.flags; + // Default name to slug if not provided + const name = nameFlag ?? slug; + // Parse proxy configurations const proxyConfigs = proxyStrings?.map((p) => parseProxyString(p)); @@ -219,7 +229,7 @@ export default class MrtEnvCreate extends MrtCommand { ); try { - const result = await createEnv( + let result = await createEnv( { projectSlug: project, slug, @@ -237,6 +247,32 @@ export default class MrtEnvCreate extends MrtCommand { this.getMrtAuth(), ); + // Wait for environment to be ready if requested + if (wait) { + this.log(t('commands.mrt.env.create.waiting', 'Waiting for environment "{{slug}}" to be ready...', {slug})); + + const waitStartTime = Date.now(); + result = await waitForEnv( + { + projectSlug: project, + slug, + origin: this.resolvedConfig.mrtOrigin, + onPoll: (env) => { + if (!this.jsonEnabled()) { + const elapsed = Math.round((Date.now() - waitStartTime) / 1000); + this.log( + t('commands.mrt.env.create.state', '[{{elapsed}}s] State: {{state}}', { + elapsed: String(elapsed), + state: env.state ?? 'unknown', + }), + ); + } + }, + }, + this.getMrtAuth(), + ); + } + if (this.jsonEnabled()) { return result; } diff --git a/packages/b2c-cli/src/commands/ods/create.ts b/packages/b2c-cli/src/commands/ods/create.ts index 31fb3222..4cde1523 100644 --- a/packages/b2c-cli/src/commands/ods/create.ts +++ b/packages/b2c-cli/src/commands/ods/create.ts @@ -253,7 +253,6 @@ export default class OdsCreate extends OdsCommand { const startTime = Date.now(); const pollIntervalMs = pollIntervalSeconds * 1000; const timeoutMs = timeoutSeconds * 1000; - let lastState: SandboxState | undefined; this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to be ready...')); @@ -285,17 +284,14 @@ export default class OdsCreate extends OdsCommand { const sandbox = result.data.data; const currentState = sandbox.state as SandboxState; - // Log state changes - if (currentState !== lastState) { - const elapsed = Math.round((Date.now() - startTime) / 1000); - this.log( - t('commands.ods.create.stateChange', '[{{elapsed}}s] State: {{state}}', { - elapsed: String(elapsed), - state: currentState || 'unknown', - }), - ); - lastState = currentState; - } + // Log current state on each poll + const elapsed = Math.round((Date.now() - startTime) / 1000); + this.log( + t('commands.ods.create.stateChange', '[{{elapsed}}s] State: {{state}}', { + elapsed: String(elapsed), + state: currentState || 'unknown', + }), + ); // Check for terminal states if (currentState && TERMINAL_STATES.has(currentState)) { diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/env.ts b/packages/b2c-tooling-sdk/src/operations/mrt/env.ts index 18603dae..ed1b0228 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/env.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/env.ts @@ -20,6 +20,11 @@ import {getLogger} from '../../logging/logger.js'; */ export type MrtEnvironment = components['schemas']['APITargetV2Create']; +/** + * Environment state from the MRT API. + */ +export type MrtEnvironmentState = components['schemas']['StateEnum']; + type SsrRegion = components['schemas']['SsrRegionEnum']; type LogLevel = components['schemas']['LogLevelEnum']; @@ -271,3 +276,173 @@ export async function deleteEnv(options: DeleteEnvOptions, auth: AuthStrategy): logger.debug({slug}, '[MRT] Environment deleted successfully'); } + +/** + * Options for getting an MRT environment. + */ +export interface GetEnvOptions { + /** + * The project slug containing the environment. + */ + projectSlug: string; + + /** + * Environment slug/identifier to retrieve. + */ + slug: string; + + /** + * MRT API origin URL. + * @default "https://cloud.mobify.com" + */ + origin?: string; +} + +/** + * Gets an environment (target) from an MRT project. + * + * @param options - Environment retrieval options + * @param auth - Authentication strategy (ApiKeyStrategy) + * @returns The environment object from the API + * @throws Error if retrieval fails + * + * @example + * ```typescript + * import { ApiKeyStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + * import { getEnv } from '@salesforce/b2c-tooling-sdk/operations/mrt'; + * + * const auth = new ApiKeyStrategy(process.env.MRT_API_KEY!, 'Authorization'); + * + * const env = await getEnv({ + * projectSlug: 'my-storefront', + * slug: 'staging' + * }, auth); + * + * console.log(`Environment state: ${env.state}`); + * ``` + */ +export async function getEnv(options: GetEnvOptions, auth: AuthStrategy): Promise { + const logger = getLogger(); + const {projectSlug, slug, origin} = options; + + logger.debug({projectSlug, slug}, '[MRT] Getting environment'); + + const client = createMrtClient({origin: origin || DEFAULT_MRT_ORIGIN}, auth); + + const {data, error} = await client.GET('/api/projects/{project_slug}/target/{target_slug}/', { + params: { + path: {project_slug: projectSlug, target_slug: slug}, + }, + }); + + if (error) { + const errorMessage = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as {message: unknown}).message) + : JSON.stringify(error); + throw new Error(`Failed to get environment: ${errorMessage}`); + } + + logger.debug({slug: data.slug, state: data.state}, '[MRT] Environment retrieved'); + + return data; +} + +/** + * Terminal states for MRT environments (no longer changing). + */ +const TERMINAL_STATES: MrtEnvironmentState[] = ['ACTIVE', 'CREATE_FAILED', 'PUBLISH_FAILED']; + +/** + * Options for waiting for an MRT environment to be ready. + */ +export interface WaitForEnvOptions extends GetEnvOptions { + /** + * Polling interval in milliseconds. + * @default 10000 + */ + pollInterval?: number; + + /** + * Maximum time to wait in milliseconds. + * @default 2700000 (45 minutes) + */ + timeout?: number; + + /** + * Optional callback called on each poll with the current environment state. + */ + onPoll?: (env: MrtEnvironment) => void; +} + +/** + * Waits for an environment to reach a terminal state (ACTIVE or failed). + * + * Polls the environment status until it reaches ACTIVE, CREATE_FAILED, + * or PUBLISH_FAILED state, or until the timeout is reached. + * + * @param options - Wait options including polling interval and timeout + * @param auth - Authentication strategy (ApiKeyStrategy) + * @returns The environment in its terminal state + * @throws Error if timeout is reached or environment fails + * + * @example + * ```typescript + * import { ApiKeyStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + * import { createEnv, waitForEnv } from '@salesforce/b2c-tooling-sdk/operations/mrt'; + * + * const auth = new ApiKeyStrategy(process.env.MRT_API_KEY!, 'Authorization'); + * + * // Create environment + * const env = await createEnv({ + * projectSlug: 'my-storefront', + * slug: 'staging', + * name: 'Staging' + * }, auth); + * + * // Wait for it to be ready + * const readyEnv = await waitForEnv({ + * projectSlug: 'my-storefront', + * slug: 'staging', + * timeout: 60000, // 1 minute + * onPoll: (e) => console.log(`State: ${e.state}`) + * }, auth); + * + * if (readyEnv.state === 'ACTIVE') { + * console.log('Environment is ready!'); + * } + * ``` + */ +export async function waitForEnv(options: WaitForEnvOptions, auth: AuthStrategy): Promise { + const logger = getLogger(); + const {projectSlug, slug, pollInterval = 10000, timeout = 2700000, onPoll, origin} = options; + + logger.debug({projectSlug, slug, pollInterval, timeout}, '[MRT] Waiting for environment'); + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const env = await getEnv({projectSlug, slug, origin}, auth); + + if (onPoll) { + onPoll(env); + } + + if (env.state && TERMINAL_STATES.includes(env.state as MrtEnvironmentState)) { + if (env.state === 'CREATE_FAILED') { + throw new Error(`Environment creation failed`); + } + if (env.state === 'PUBLISH_FAILED') { + throw new Error(`Environment publish failed`); + } + logger.debug({slug, state: env.state}, '[MRT] Environment reached terminal state'); + return env; + } + + logger.debug({slug, state: env.state, elapsed: Date.now() - startTime}, '[MRT] Environment still in progress'); + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + throw new Error(`Timeout waiting for environment "${slug}" to be ready after ${timeout}ms`); +} diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts index 1c3b4d61..117e716e 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts @@ -65,5 +65,12 @@ export type { } from './env-var.js'; // Environment (target) operations -export {createEnv, deleteEnv} from './env.js'; -export type {CreateEnvOptions, DeleteEnvOptions, MrtEnvironment} from './env.js'; +export {createEnv, deleteEnv, getEnv, waitForEnv} from './env.js'; +export type { + CreateEnvOptions, + DeleteEnvOptions, + GetEnvOptions, + WaitForEnvOptions, + MrtEnvironment, + MrtEnvironmentState, +} from './env.js';