Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,33 @@ The B2C CLI supports multiple authentication methods and configuration options.

## Authentication Methods

### OAuth (Recommended)
The CLI supports multiple auth methods that can be specified via the `--auth-methods` flag:

OAuth authentication is the recommended method for production use. It uses the Account Manager OAuth flow.
- `client-credentials` - OAuth 2.0 client credentials flow (requires client ID and secret)
- `implicit` - OAuth 2.0 implicit flow (requires client ID only, opens browser for login)
- `basic` - Basic authentication (for WebDAV operations)
- `api-key` - API key authentication

### Specifying Auth Methods

You can specify allowed auth methods in priority order using comma-separated values or multiple flags:

```bash
# Comma-separated (preferred)
b2c code deploy --auth-methods client-credentials,implicit

# Multiple flags (also supported)
b2c code deploy --auth-methods client-credentials --auth-methods implicit

# Via environment variable
SFCC_AUTH_METHODS=client-credentials,implicit b2c code deploy
```

The CLI will try each method in order until one succeeds. If no methods are specified, the default is `client-credentials,implicit`.

### OAuth Client Credentials (Recommended)

OAuth authentication using client credentials is the recommended method for production and CI/CD use.

```bash
b2c code deploy \
Expand All @@ -15,9 +39,20 @@ b2c code deploy \
--client-secret your-client-secret
```

### OAuth Implicit Flow

For development without a client secret, use implicit flow which opens a browser for authentication:

```bash
b2c code deploy \
--server your-instance.demandware.net \
--client-id your-client-id \
--auth-methods implicit
```

### Basic Authentication

For development and testing, you can use basic authentication with Business Manager credentials.
For development and testing, you can use basic authentication with Business Manager credentials:

```bash
b2c code deploy \
Expand All @@ -32,15 +67,18 @@ For certain operations, you may use an API key.

## Environment Variables

You can also configure authentication using environment variables:
You can configure authentication using environment variables:

| Variable | Description |
|----------|-------------|
| `B2C_SERVER` | The B2C instance hostname |
| `B2C_CLIENT_ID` | OAuth client ID |
| `B2C_CLIENT_SECRET` | OAuth client secret |
| `B2C_USERNAME` | Basic auth username |
| `B2C_PASSWORD` | Basic auth password |
| `SFCC_SERVER` | The B2C instance hostname |
| `SFCC_CLIENT_ID` | OAuth client ID |
| `SFCC_CLIENT_SECRET` | OAuth client secret |
| `SFCC_USERNAME` | Basic auth username |
| `SFCC_PASSWORD` | Basic auth password |
| `SFCC_AUTH_METHODS` | Comma-separated list of allowed auth methods |
| `SFCC_OAUTH_SCOPES` | OAuth scopes to request |
| `SFCC_CODE_VERSION` | Code version for deployments |

## Configuration File

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
"description": "Salesforce Commerce Cloud B2C Command Line Tools",
"main": "index.js",
"scripts": {
"test": "pnpm -r test",
"start": "pnpm --filter @salesforce/b2c-cli run dev",
"test": "pnpm -r test",
"format": "pnpm -r run format",
"lint": "pnpm -r run lint",
"build": "pnpm -r run build",
"docs:api": "typedoc",
"docs:dev": "pnpm run docs:api && vitepress dev docs",
"docs:build": "pnpm run docs:api && vitepress build docs",
Expand Down
2 changes: 2 additions & 0 deletions packages/b2c-cli/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export default [
'no-warning-comments': 'off',
// Don't require destructuring
'prefer-destructuring': 'off',
// Disable new-cap - incompatible with openapi-fetch (uses GET, POST, etc. methods)
'new-cap': 'off',
// Allow underscore-prefixed unused variables
'@typescript-eslint/no-unused-vars': [
'error',
Expand Down
241 changes: 241 additions & 0 deletions packages/b2c-cli/src/commands/ods/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import {Flags, ux} from '@oclif/core';
import cliui from 'cliui';
import {OdsCommand} from '@salesforce/b2c-tooling/cli';
import type {OdsComponents} from '@salesforce/b2c-tooling';
import {t} from '../../i18n/index.js';

type SandboxModel = OdsComponents['schemas']['SandboxModel'];
type SandboxResourceProfile = OdsComponents['schemas']['SandboxResourceProfile'];
type SandboxState = OdsComponents['schemas']['SandboxState'];

/** States that indicate sandbox creation has completed (success or failure) */
const TERMINAL_STATES = new Set<SandboxState>(['deleted', 'failed', 'started']);

/**
* Command to create a new on-demand sandbox.
*/
export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
static description = t('commands.ods.create.description', 'Create a new on-demand sandbox');

static enableJsonFlag = true;

static examples = [
'<%= config.bin %> <%= command.id %> --realm abcd',
'<%= config.bin %> <%= command.id %> --realm abcd --ttl 48',
'<%= config.bin %> <%= command.id %> --realm abcd --profile large',
'<%= config.bin %> <%= command.id %> --realm abcd --auto-scheduled',
'<%= config.bin %> <%= command.id %> --realm abcd --wait',
'<%= config.bin %> <%= command.id %> --realm abcd --wait --poll-interval 15',
'<%= config.bin %> <%= command.id %> --realm abcd --json',
];

static flags = {
realm: Flags.string({
char: 'r',
description: 'Realm ID (four-letter ID)',
required: true,
}),
ttl: Flags.integer({
description: 'Time to live in hours (0 for infinite)',
default: 24,
}),
profile: Flags.string({
description: 'Resource profile (medium, large, xlarge, xxlarge)',
default: 'medium',
options: ['medium', 'large', 'xlarge', 'xxlarge'],
}),
'auto-scheduled': Flags.boolean({
description: 'Enable automatic start/stop scheduling',
default: false,
}),
wait: Flags.boolean({
char: 'w',
description: 'Wait for the sandbox to reach started or failed state before returning',
default: false,
}),
'poll-interval': Flags.integer({
description: 'Polling interval in seconds when using --wait',
default: 10,
dependsOn: ['wait'],
}),
timeout: Flags.integer({
description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)',
default: 600,
dependsOn: ['wait'],
}),
};

async run(): Promise<SandboxModel> {
const realm = this.flags.realm;
const profile = this.flags.profile as SandboxResourceProfile;
const ttl = this.flags.ttl;
const autoScheduled = this.flags['auto-scheduled'];
const wait = this.flags.wait;
const pollInterval = this.flags['poll-interval'];
const timeout = this.flags.timeout;

this.log(t('commands.ods.create.creating', 'Creating sandbox in realm {{realm}}...', {realm}));
this.log(t('commands.ods.create.profile', 'Profile: {{profile}}', {profile}));
this.log(t('commands.ods.create.ttl', 'TTL: {{ttl}} hours', {ttl: ttl === 0 ? 'infinite' : String(ttl)}));

const result = await this.odsClient.POST('/sandboxes', {
body: {
realm,
ttl,
resourceProfile: profile,
autoScheduled,
analyticsEnabled: false,
},
});

if (!result.data?.data) {
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
this.error(
t('commands.ods.create.error', 'Failed to create sandbox: {{message}}', {
message: errorMessage,
}),
);
}

let sandbox = result.data.data;

this.log('');
this.log(t('commands.ods.create.success', 'Sandbox created successfully!'));

if (wait && sandbox.id) {
this.log('');
sandbox = await this.waitForSandbox(sandbox.id, pollInterval, timeout);
}

if (this.jsonEnabled()) {
return sandbox;
}

this.printSandboxSummary(sandbox);

return sandbox;
}

private printSandboxSummary(sandbox: SandboxModel): void {
const ui = cliui({width: process.stdout.columns || 80});

ui.div({text: '', padding: [0, 0, 0, 0]});

const fields: [string, string | undefined][] = [
['ID', sandbox.id],
['Realm', sandbox.realm],
['Instance', sandbox.instance],
['State', sandbox.state],
['Profile', sandbox.resourceProfile],
['Hostname', sandbox.hostName],
];

for (const [label, value] of fields) {
if (value !== undefined) {
ui.div({text: `${label}:`, width: 15, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]});
}
}

if (sandbox.links?.bm) {
ui.div({text: '', padding: [0, 0, 0, 0]});
ui.div({text: 'BM URL:', width: 15, padding: [0, 2, 0, 0]}, {text: sandbox.links.bm, padding: [0, 0, 0, 0]});
}

ux.stdout(ui.toString());
}

/**
* Sleep for a given number of milliseconds.
*/
private async sleep(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

/**
* Polls for sandbox status until it reaches a terminal state.
* @param sandboxId - The sandbox ID to poll
* @param pollIntervalSeconds - Interval between polls in seconds
* @param timeoutSeconds - Maximum time to wait (0 for no timeout)
* @returns The final sandbox state
*/
private async waitForSandbox(
sandboxId: string,
pollIntervalSeconds: number,
timeoutSeconds: number,
): Promise<SandboxModel> {
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...'));

while (true) {
// Check for timeout
if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) {
this.error(
t('commands.ods.create.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
seconds: String(timeoutSeconds),
}),
);
}

// eslint-disable-next-line no-await-in-loop
const result = await this.odsClient.GET('/sandboxes/{sandboxId}', {
params: {
path: {sandboxId},
},
});

if (!result.data?.data) {
this.error(
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
message: result.response?.statusText || 'Unknown error',
}),
);
}

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;
}

// Check for terminal states
if (currentState && TERMINAL_STATES.has(currentState)) {
switch (currentState) {
case 'deleted': {
this.error(t('commands.ods.create.deleted', 'Sandbox was deleted'));
break;
}
case 'failed': {
this.error(t('commands.ods.create.failed', 'Sandbox creation failed'));
break;
}
case 'started': {
this.log('');
this.log(t('commands.ods.create.ready', 'Sandbox is now ready!'));
break;
}
}
return sandbox;
}

// Wait before next poll
// eslint-disable-next-line no-await-in-loop
await this.sleep(pollIntervalMs);
}
}
}
Loading