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
6 changes: 6 additions & 0 deletions .changeset/add-code-download-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@salesforce/b2c-cli': minor
'@salesforce/b2c-tooling-sdk': minor
---

Add `b2c code download` command to download cartridge code from a B2C Commerce instance, with support for cartridge filtering, mirror mode, and progress reporting
80 changes: 76 additions & 4 deletions docs/cli/code.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Commands for deploying cartridges, activating code versions, and watching for file changes on B2C Commerce instances.
description: Commands for deploying, downloading, activating code versions, and watching for file changes on B2C Commerce instances.
---

# Code Commands
Expand All @@ -12,12 +12,12 @@ Code commands use different authentication depending on the operation:

| Operation | Auth Required |
|-----------|--------------|
| `code deploy`, `code watch` | WebDAV (Basic Auth or OAuth) |
| `code deploy`, `code download`, `code watch` | WebDAV (Basic Auth or OAuth) |
| `code list`, `code activate`, `code delete` | OAuth + OCAPI |

### WebDAV Operations (deploy, watch)
### WebDAV Operations (deploy, download, watch)

File upload operations require WebDAV access. Basic authentication is recommended:
File transfer operations require WebDAV access. Basic authentication is recommended:

```bash
export SFCC_USERNAME=your-bm-username
Expand Down Expand Up @@ -157,6 +157,78 @@ Cartridges are discovered by searching for `.project` files (Eclipse project mar

---

## b2c code download

Download cartridge code from a B2C Commerce instance.

This command triggers server-side zipping of the code version, downloads the archive, and extracts cartridges locally. It is the inverse of `code deploy`.

### Usage

```bash
b2c code download [CARTRIDGEPATH]
```

### Arguments

| Argument | Description | Default |
|----------|-------------|---------|
| `CARTRIDGEPATH` | Path to search for local cartridges (used with `--mirror`) | `.` (current directory) |

### Flags

In addition to [global flags](./index#global-flags):

| Flag | Description | Default |
|------|-------------|---------|
| `--output`, `-o` | Output directory for downloaded cartridges | `cartridges` |
| `--mirror`, `-m` | Extract cartridges to their local project locations | `false` |
| `--cartridge`, `-c` | Include specific cartridge(s) (can be repeated) | |
| `--exclude-cartridge`, `-x` | Exclude specific cartridge(s) (can be repeated) | |

### Examples

```bash
# Download all cartridges from the active code version
b2c code download --server my-sandbox.demandware.net

# Download to a specific output directory
b2c code download -o ./downloaded

# Download a specific code version
b2c code download --server my-sandbox.demandware.net --code-version v1

# Download only specific cartridges
b2c code download -c app_storefront_base -c plugin_applepay

# Exclude certain cartridges
b2c code download -x test_cartridge -x int_debug

# Mirror: extract to local cartridge project locations
b2c code download --mirror

# Using environment variables
export SFCC_SERVER=my-sandbox.demandware.net
export SFCC_CODE_VERSION=v1
export SFCC_USERNAME=my-user
export SFCC_PASSWORD=my-access-key
b2c code download -o ./backup
```

### Mirror Mode

With `--mirror`, instead of extracting all cartridges into the output directory, each cartridge is extracted to its local project location (discovered via `.project` files, same as deploy). This is useful for syncing remote code changes back to your local project.

If a cartridge exists remotely but not locally, it is extracted to the output directory as a fallback.

### Notes

- If no `--code-version` is specified, the command auto-discovers the active code version via OCAPI (requires OAuth credentials)
- Existing file permissions are preserved when overwriting files
- The server-side zip is cleaned up automatically after download

---

## b2c code activate

Activate a code version on a B2C Commerce instance, or reload the current active version.
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Safety Mode operates at the HTTP layer and cannot be bypassed by command-line fl

### Instance Operations

- [Code Commands](./code) - Deploy cartridges and manage code versions
- [Code Commands](./code) - Deploy, download, and manage code versions
- [Job Commands](./jobs) - Execute and monitor jobs, import/export site archives
- [Sites Commands](./sites) - List and manage sites
- [WebDAV Commands](./webdav) - File operations on instance WebDAV
Expand Down
18 changes: 17 additions & 1 deletion packages/b2c-cli/src/commands/code/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
export default class CodeDeploy extends CartridgeCommand<typeof CodeDeploy> {
static hiddenAliases = ['code:deploy'];

static args = {

Check warning on line 21 in packages/b2c-cli/src/commands/code/deploy.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Expected "args" to come before "hiddenAliases"

Check warning on line 21 in packages/b2c-cli/src/commands/code/deploy.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Expected "args" to come before "hiddenAliases"
...CartridgeCommand.baseArgs,
};

Expand Down Expand Up @@ -69,7 +69,7 @@
reloadCodeVersion,
};

async run(): Promise<DeployResult> {

Check warning on line 72 in packages/b2c-cli/src/commands/code/deploy.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Async method 'run' has a complexity of 24. Maximum allowed is 20

Check warning on line 72 in packages/b2c-cli/src/commands/code/deploy.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Async method 'run' has a complexity of 24. Maximum allowed is 20
this.requireWebDavCredentials();

const hostname = this.resolvedConfig.values.hostname!;
Expand Down Expand Up @@ -177,7 +177,23 @@
}

// Upload cartridges
await this.operations.uploadCartridges(this.instance, cartridges);
const uploadPhaseLabels = {
archiving: t('commands.code.deploy.archiving', 'Creating cartridge archive'),
uploading: t('commands.code.deploy.uploading', 'Uploading archive'),
unzipping: t('commands.code.deploy.unzipping', 'Unzipping on server'),
cleanup: t('commands.code.deploy.cleanup', 'Cleaning up'),
};
await this.operations.uploadCartridges(this.instance, cartridges, {
onProgress: (info) => {
if (this.jsonEnabled()) return;
const label = uploadPhaseLabels[info.phase];
if (info.elapsedSeconds === 0) {
this.log(` ${label}...`);
} else {
this.log(` ${label}... (${info.elapsedSeconds}s elapsed)`);
}
},
});

// Optionally activate or reload code version
let activated = false;
Expand Down
207 changes: 207 additions & 0 deletions packages/b2c-cli/src/commands/code/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* 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 {Flags} from '@oclif/core';
import {
downloadCartridges,
getActiveCodeVersion,
type DownloadResult,
} from '@salesforce/b2c-tooling-sdk/operations/code';
import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli';
import {t, withDocs} from '../../i18n/index.js';

export default class CodeDownload extends CartridgeCommand<typeof CodeDownload> {
static hiddenAliases = ['code:download'];

static args = {

Check warning on line 18 in packages/b2c-cli/src/commands/code/download.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Expected "args" to come before "hiddenAliases"

Check warning on line 18 in packages/b2c-cli/src/commands/code/download.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Expected "args" to come before "hiddenAliases"
...CartridgeCommand.baseArgs,
};

static description = withDocs(
t('commands.code.download.description', 'Download cartridge code from a B2C Commerce instance'),
'/cli/code.html#b2c-code-download',
);

static enableJsonFlag = true;

static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> -o ./downloaded',
'<%= config.bin %> <%= command.id %> --server my-sandbox.demandware.net --code-version v1',
'<%= config.bin %> <%= command.id %> --mirror',
'<%= config.bin %> <%= command.id %> -c app_storefront_base -c plugin_applepay',
'<%= config.bin %> <%= command.id %> -x test_cartridge',
];

static flags = {
...CartridgeCommand.baseFlags,
...CartridgeCommand.cartridgeFlags,
output: Flags.string({
char: 'o',
description: 'Output directory for downloaded cartridges',
default: 'cartridges',
exclusive: ['mirror'],
}),
mirror: Flags.boolean({
char: 'm',
description: 'Extract cartridges to their local project locations',
default: false,
exclusive: ['output'],
}),
};

protected operations = {
downloadCartridges,
getActiveCodeVersion,
};

async run(): Promise<DownloadResult> {
this.requireWebDavCredentials();

const hostname = this.resolvedConfig.values.hostname!;
let version = this.resolvedConfig.values.codeVersion;

const needsOAuth = !version;
if (needsOAuth && !this.hasOAuthCredentials()) {
this.error(
t(
'commands.code.download.oauthRequired',
'No code version specified. OAuth credentials are required to auto-discover the active code version.\n\nProvide --code-version to use basic auth only, or configure OAuth credentials.\nSee: https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/configuration.html',
),
);
}

// If no code version specified, discover the active one
if (!version) {
this.warn(
t('commands.code.download.noCodeVersion', 'No code version specified, discovering active code version...'),
);
const activeVersion = await this.operations.getActiveCodeVersion(this.instance);
if (!activeVersion?.id) {
this.error(
t('commands.code.download.noActiveVersion', 'No active code version found. Specify one with --code-version.'),
);
}
version = activeVersion.id;
this.instance.config.codeVersion = version;
}

// Build mirror map if --mirror flag is set
let mirror: Map<string, string> | undefined;
if (this.flags.mirror) {
const cartridges = await this.findCartridgesWithProviders();
if (cartridges.length === 0) {
this.error(
t('commands.code.download.noLocalCartridges', 'No local cartridges found in {{path}} for mirror mode', {
path: this.cartridgePath,
}),
);
}
mirror = new Map(cartridges.map((c) => [c.name, c.src]));
}

// Create lifecycle context
const context = this.createContext('code:download', {
cartridgePath: this.cartridgePath,
hostname,
codeVersion: version,
mirror: this.flags.mirror,
output: this.flags.output,
...this.cartridgeOptions,
});

// Run beforeOperation hooks
const beforeResult = await this.runBeforeHooks(context);
if (beforeResult.skip) {
this.log(
t('commands.code.download.skipped', 'Download skipped: {{reason}}', {
reason: beforeResult.skipReason || 'skipped by plugin',
}),
);
return {
cartridges: [],
codeVersion: version,
outputDirectory: this.flags.output ?? 'cartridges',
};
}

this.log(
t('commands.code.download.downloading', 'Downloading code version "{{version}}" from {{hostname}}...', {
version,
hostname,
}),
);

// Temporarily allow DELETE for zip cleanup
const cleanupSafetyRule = this.safetyGuard.temporarilyAddRule({
method: 'DELETE',
path: '**/Cartridges/*.zip',
action: 'allow',
});

try {
const phaseLabels = {
zipping: t('commands.code.download.zipping', 'Archiving code version'),
downloading: t('commands.code.download.downloadingZip', 'Downloading cartridges'),
cleanup: t('commands.code.download.cleanup', 'Cleaning up'),
extracting: t('commands.code.download.extracting', 'Extracting cartridges'),
};

const result = await this.operations.downloadCartridges(this.instance, this.flags.output ?? 'cartridges', {
include: this.cartridgeOptions.include,
exclude: this.cartridgeOptions.exclude,
mirror,
onProgress: (info) => {
if (this.jsonEnabled()) return;
const label = phaseLabels[info.phase];
if (info.elapsedSeconds === 0) {
this.log(` ${label}...`);
} else {
this.log(
t('commands.code.download.elapsed', ' {{label}}... ({{elapsed}}s elapsed)', {
label,
elapsed: String(info.elapsedSeconds),
}),
);
}
},
});

this.log(
t('commands.code.download.summary', 'Downloaded {{count}} cartridge(s) from version "{{codeVersion}}"', {
count: result.cartridges.length,
codeVersion: result.codeVersion,
}),
);

for (const name of result.cartridges) {
this.log(` ${name}`);
}

// Run afterOperation hooks with success
await this.runAfterHooks(context, {
success: true,
duration: Date.now() - context.startTime,
data: result,
});

return result;
} catch (error) {
// Run afterOperation hooks with failure
await this.runAfterHooks(context, {
success: false,
error: error instanceof Error ? error : new Error(String(error)),
duration: Date.now() - context.startTime,
});

if (error instanceof Error) {
this.error(t('commands.code.download.failed', 'Download failed: {{message}}', {message: error.message}));
}
throw error;
} finally {
cleanupSafetyRule();
}
}
}
6 changes: 6 additions & 0 deletions packages/b2c-cli/src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export const de = {
complete: 'Deployment abgeschlossen',
failed: 'Deployment fehlgeschlagen: {{message}}',
},
download: {
description: 'Cartridge-Code von einer B2C Commerce-Instanz herunterladen',
downloading: 'Lade Code-Version "{{version}}" von {{hostname}} herunter...',
summary: '{{count}} Cartridge(s) von Version "{{codeVersion}}" heruntergeladen',
failed: 'Download fehlgeschlagen: {{message}}',
},
},
sandbox: {
create: {
Expand Down
20 changes: 20 additions & 0 deletions packages/b2c-cli/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,30 @@ export const en = {
deploying: 'Deploying {{path}} to {{hostname}} ({{version}})',
noCodeVersion: 'No code version specified, discovering active code version...',
noActiveVersion: 'No active code version found. Specify one with --code-version.',
archiving: 'Creating cartridge archive...',
uploading: 'Uploading archive...',
unzipping: 'Unzipping on server...',
cleanup: 'Cleaning up...',
summary: 'Deployed {{count}} cartridge(s) to {{codeVersion}}',
reloaded: 'Code version reloaded',
failed: 'Deployment failed: {{message}}',
},
download: {
description: 'Download cartridge code from a B2C Commerce instance',
downloading: 'Downloading code version "{{version}}" from {{hostname}}...',
noCodeVersion: 'No code version specified, discovering active code version...',
noActiveVersion: 'No active code version found. Specify one with --code-version.',
noLocalCartridges: 'No local cartridges found in {{path}} for mirror mode',
oauthRequired:
'No code version specified. OAuth credentials are required to auto-discover the active code version.',
skipped: 'Download skipped: {{reason}}',
zipping: 'Archiving code version',
downloadingZip: 'Downloading cartridges',
cleanup: 'Cleaning up',
extracting: 'Extracting cartridges',
summary: 'Downloaded {{count}} cartridge(s) from version "{{codeVersion}}"',
failed: 'Download failed: {{message}}',
},
watch: {
description: 'Watch cartridges and upload changes to an instance',
starting: 'Starting watcher for {{path}}',
Expand Down
Loading
Loading