Skip to content

Commit e1ed9f4

Browse files
authored
ODS sandbox management commands (#6)
* ods wip * ods command set * filtering and extra params support * better display * fix hostname defaults and overrides * lint * sandbox polling
1 parent ffd2217 commit e1ed9f4

36 files changed

+7885
-101
lines changed

docs/guide/configuration.md

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,33 @@ The B2C CLI supports multiple authentication methods and configuration options.
44

55
## Authentication Methods
66

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

9-
OAuth authentication is the recommended method for production use. It uses the Account Manager OAuth flow.
9+
- `client-credentials` - OAuth 2.0 client credentials flow (requires client ID and secret)
10+
- `implicit` - OAuth 2.0 implicit flow (requires client ID only, opens browser for login)
11+
- `basic` - Basic authentication (for WebDAV operations)
12+
- `api-key` - API key authentication
13+
14+
### Specifying Auth Methods
15+
16+
You can specify allowed auth methods in priority order using comma-separated values or multiple flags:
17+
18+
```bash
19+
# Comma-separated (preferred)
20+
b2c code deploy --auth-methods client-credentials,implicit
21+
22+
# Multiple flags (also supported)
23+
b2c code deploy --auth-methods client-credentials --auth-methods implicit
24+
25+
# Via environment variable
26+
SFCC_AUTH_METHODS=client-credentials,implicit b2c code deploy
27+
```
28+
29+
The CLI will try each method in order until one succeeds. If no methods are specified, the default is `client-credentials,implicit`.
30+
31+
### OAuth Client Credentials (Recommended)
32+
33+
OAuth authentication using client credentials is the recommended method for production and CI/CD use.
1034

1135
```bash
1236
b2c code deploy \
@@ -15,9 +39,20 @@ b2c code deploy \
1539
--client-secret your-client-secret
1640
```
1741

42+
### OAuth Implicit Flow
43+
44+
For development without a client secret, use implicit flow which opens a browser for authentication:
45+
46+
```bash
47+
b2c code deploy \
48+
--server your-instance.demandware.net \
49+
--client-id your-client-id \
50+
--auth-methods implicit
51+
```
52+
1853
### Basic Authentication
1954

20-
For development and testing, you can use basic authentication with Business Manager credentials.
55+
For development and testing, you can use basic authentication with Business Manager credentials:
2156

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

3368
## Environment Variables
3469

35-
You can also configure authentication using environment variables:
70+
You can configure authentication using environment variables:
3671

3772
| Variable | Description |
3873
|----------|-------------|
39-
| `B2C_SERVER` | The B2C instance hostname |
40-
| `B2C_CLIENT_ID` | OAuth client ID |
41-
| `B2C_CLIENT_SECRET` | OAuth client secret |
42-
| `B2C_USERNAME` | Basic auth username |
43-
| `B2C_PASSWORD` | Basic auth password |
74+
| `SFCC_SERVER` | The B2C instance hostname |
75+
| `SFCC_CLIENT_ID` | OAuth client ID |
76+
| `SFCC_CLIENT_SECRET` | OAuth client secret |
77+
| `SFCC_USERNAME` | Basic auth username |
78+
| `SFCC_PASSWORD` | Basic auth password |
79+
| `SFCC_AUTH_METHODS` | Comma-separated list of allowed auth methods |
80+
| `SFCC_OAUTH_SCOPES` | OAuth scopes to request |
81+
| `SFCC_CODE_VERSION` | Code version for deployments |
4482

4583
## Configuration File
4684

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
"description": "Salesforce Commerce Cloud B2C Command Line Tools",
55
"main": "index.js",
66
"scripts": {
7-
"test": "pnpm -r test",
87
"start": "pnpm --filter @salesforce/b2c-cli run dev",
8+
"test": "pnpm -r test",
9+
"format": "pnpm -r run format",
10+
"lint": "pnpm -r run lint",
11+
"build": "pnpm -r run build",
912
"docs:api": "typedoc",
1013
"docs:dev": "pnpm run docs:api && vitepress dev docs",
1114
"docs:build": "pnpm run docs:api && vitepress build docs",

packages/b2c-cli/eslint.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export default [
2626
'no-warning-comments': 'off',
2727
// Don't require destructuring
2828
'prefer-destructuring': 'off',
29+
// Disable new-cap - incompatible with openapi-fetch (uses GET, POST, etc. methods)
30+
'new-cap': 'off',
2931
// Allow underscore-prefixed unused variables
3032
'@typescript-eslint/no-unused-vars': [
3133
'error',
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import {Flags, ux} from '@oclif/core';
2+
import cliui from 'cliui';
3+
import {OdsCommand} from '@salesforce/b2c-tooling/cli';
4+
import type {OdsComponents} from '@salesforce/b2c-tooling';
5+
import {t} from '../../i18n/index.js';
6+
7+
type SandboxModel = OdsComponents['schemas']['SandboxModel'];
8+
type SandboxResourceProfile = OdsComponents['schemas']['SandboxResourceProfile'];
9+
type SandboxState = OdsComponents['schemas']['SandboxState'];
10+
11+
/** States that indicate sandbox creation has completed (success or failure) */
12+
const TERMINAL_STATES = new Set<SandboxState>(['deleted', 'failed', 'started']);
13+
14+
/**
15+
* Command to create a new on-demand sandbox.
16+
*/
17+
export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
18+
static description = t('commands.ods.create.description', 'Create a new on-demand sandbox');
19+
20+
static enableJsonFlag = true;
21+
22+
static examples = [
23+
'<%= config.bin %> <%= command.id %> --realm abcd',
24+
'<%= config.bin %> <%= command.id %> --realm abcd --ttl 48',
25+
'<%= config.bin %> <%= command.id %> --realm abcd --profile large',
26+
'<%= config.bin %> <%= command.id %> --realm abcd --auto-scheduled',
27+
'<%= config.bin %> <%= command.id %> --realm abcd --wait',
28+
'<%= config.bin %> <%= command.id %> --realm abcd --wait --poll-interval 15',
29+
'<%= config.bin %> <%= command.id %> --realm abcd --json',
30+
];
31+
32+
static flags = {
33+
realm: Flags.string({
34+
char: 'r',
35+
description: 'Realm ID (four-letter ID)',
36+
required: true,
37+
}),
38+
ttl: Flags.integer({
39+
description: 'Time to live in hours (0 for infinite)',
40+
default: 24,
41+
}),
42+
profile: Flags.string({
43+
description: 'Resource profile (medium, large, xlarge, xxlarge)',
44+
default: 'medium',
45+
options: ['medium', 'large', 'xlarge', 'xxlarge'],
46+
}),
47+
'auto-scheduled': Flags.boolean({
48+
description: 'Enable automatic start/stop scheduling',
49+
default: false,
50+
}),
51+
wait: Flags.boolean({
52+
char: 'w',
53+
description: 'Wait for the sandbox to reach started or failed state before returning',
54+
default: false,
55+
}),
56+
'poll-interval': Flags.integer({
57+
description: 'Polling interval in seconds when using --wait',
58+
default: 10,
59+
dependsOn: ['wait'],
60+
}),
61+
timeout: Flags.integer({
62+
description: 'Maximum time to wait in seconds when using --wait (0 for no timeout)',
63+
default: 600,
64+
dependsOn: ['wait'],
65+
}),
66+
};
67+
68+
async run(): Promise<SandboxModel> {
69+
const realm = this.flags.realm;
70+
const profile = this.flags.profile as SandboxResourceProfile;
71+
const ttl = this.flags.ttl;
72+
const autoScheduled = this.flags['auto-scheduled'];
73+
const wait = this.flags.wait;
74+
const pollInterval = this.flags['poll-interval'];
75+
const timeout = this.flags.timeout;
76+
77+
this.log(t('commands.ods.create.creating', 'Creating sandbox in realm {{realm}}...', {realm}));
78+
this.log(t('commands.ods.create.profile', 'Profile: {{profile}}', {profile}));
79+
this.log(t('commands.ods.create.ttl', 'TTL: {{ttl}} hours', {ttl: ttl === 0 ? 'infinite' : String(ttl)}));
80+
81+
const result = await this.odsClient.POST('/sandboxes', {
82+
body: {
83+
realm,
84+
ttl,
85+
resourceProfile: profile,
86+
autoScheduled,
87+
analyticsEnabled: false,
88+
},
89+
});
90+
91+
if (!result.data?.data) {
92+
const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined;
93+
const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error';
94+
this.error(
95+
t('commands.ods.create.error', 'Failed to create sandbox: {{message}}', {
96+
message: errorMessage,
97+
}),
98+
);
99+
}
100+
101+
let sandbox = result.data.data;
102+
103+
this.log('');
104+
this.log(t('commands.ods.create.success', 'Sandbox created successfully!'));
105+
106+
if (wait && sandbox.id) {
107+
this.log('');
108+
sandbox = await this.waitForSandbox(sandbox.id, pollInterval, timeout);
109+
}
110+
111+
if (this.jsonEnabled()) {
112+
return sandbox;
113+
}
114+
115+
this.printSandboxSummary(sandbox);
116+
117+
return sandbox;
118+
}
119+
120+
private printSandboxSummary(sandbox: SandboxModel): void {
121+
const ui = cliui({width: process.stdout.columns || 80});
122+
123+
ui.div({text: '', padding: [0, 0, 0, 0]});
124+
125+
const fields: [string, string | undefined][] = [
126+
['ID', sandbox.id],
127+
['Realm', sandbox.realm],
128+
['Instance', sandbox.instance],
129+
['State', sandbox.state],
130+
['Profile', sandbox.resourceProfile],
131+
['Hostname', sandbox.hostName],
132+
];
133+
134+
for (const [label, value] of fields) {
135+
if (value !== undefined) {
136+
ui.div({text: `${label}:`, width: 15, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]});
137+
}
138+
}
139+
140+
if (sandbox.links?.bm) {
141+
ui.div({text: '', padding: [0, 0, 0, 0]});
142+
ui.div({text: 'BM URL:', width: 15, padding: [0, 2, 0, 0]}, {text: sandbox.links.bm, padding: [0, 0, 0, 0]});
143+
}
144+
145+
ux.stdout(ui.toString());
146+
}
147+
148+
/**
149+
* Sleep for a given number of milliseconds.
150+
*/
151+
private async sleep(ms: number): Promise<void> {
152+
await new Promise((resolve) => {
153+
setTimeout(resolve, ms);
154+
});
155+
}
156+
157+
/**
158+
* Polls for sandbox status until it reaches a terminal state.
159+
* @param sandboxId - The sandbox ID to poll
160+
* @param pollIntervalSeconds - Interval between polls in seconds
161+
* @param timeoutSeconds - Maximum time to wait (0 for no timeout)
162+
* @returns The final sandbox state
163+
*/
164+
private async waitForSandbox(
165+
sandboxId: string,
166+
pollIntervalSeconds: number,
167+
timeoutSeconds: number,
168+
): Promise<SandboxModel> {
169+
const startTime = Date.now();
170+
const pollIntervalMs = pollIntervalSeconds * 1000;
171+
const timeoutMs = timeoutSeconds * 1000;
172+
let lastState: SandboxState | undefined;
173+
174+
this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to be ready...'));
175+
176+
while (true) {
177+
// Check for timeout
178+
if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) {
179+
this.error(
180+
t('commands.ods.create.timeout', 'Timeout waiting for sandbox after {{seconds}} seconds', {
181+
seconds: String(timeoutSeconds),
182+
}),
183+
);
184+
}
185+
186+
// eslint-disable-next-line no-await-in-loop
187+
const result = await this.odsClient.GET('/sandboxes/{sandboxId}', {
188+
params: {
189+
path: {sandboxId},
190+
},
191+
});
192+
193+
if (!result.data?.data) {
194+
this.error(
195+
t('commands.ods.create.pollError', 'Failed to fetch sandbox status: {{message}}', {
196+
message: result.response?.statusText || 'Unknown error',
197+
}),
198+
);
199+
}
200+
201+
const sandbox = result.data.data;
202+
const currentState = sandbox.state as SandboxState;
203+
204+
// Log state changes
205+
if (currentState !== lastState) {
206+
const elapsed = Math.round((Date.now() - startTime) / 1000);
207+
this.log(
208+
t('commands.ods.create.stateChange', '[{{elapsed}}s] State: {{state}}', {
209+
elapsed: String(elapsed),
210+
state: currentState || 'unknown',
211+
}),
212+
);
213+
lastState = currentState;
214+
}
215+
216+
// Check for terminal states
217+
if (currentState && TERMINAL_STATES.has(currentState)) {
218+
switch (currentState) {
219+
case 'deleted': {
220+
this.error(t('commands.ods.create.deleted', 'Sandbox was deleted'));
221+
break;
222+
}
223+
case 'failed': {
224+
this.error(t('commands.ods.create.failed', 'Sandbox creation failed'));
225+
break;
226+
}
227+
case 'started': {
228+
this.log('');
229+
this.log(t('commands.ods.create.ready', 'Sandbox is now ready!'));
230+
break;
231+
}
232+
}
233+
return sandbox;
234+
}
235+
236+
// Wait before next poll
237+
// eslint-disable-next-line no-await-in-loop
238+
await this.sleep(pollIntervalMs);
239+
}
240+
}
241+
}

0 commit comments

Comments
 (0)