Skip to content

Commit 75d9e73

Browse files
committed
feat: add code download command
Implement `b2c code download` — the inverse of `code deploy`. Downloads cartridge code from a B2C Commerce instance via WebDAV server-side ZIP, with support for cartridge filtering, mirror mode, and elapsed-time progress reporting. Also adds progress reporting to `code deploy` upload operations.
1 parent e79e275 commit 75d9e73

15 files changed

Lines changed: 1146 additions & 12 deletions

File tree

docs/cli/code.md

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Commands for deploying cartridges, activating code versions, and watching for file changes on B2C Commerce instances.
2+
description: Commands for deploying, downloading, activating code versions, and watching for file changes on B2C Commerce instances.
33
---
44

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

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

18-
### WebDAV Operations (deploy, watch)
18+
### WebDAV Operations (deploy, download, watch)
1919

20-
File upload operations require WebDAV access. Basic authentication is recommended:
20+
File transfer operations require WebDAV access. Basic authentication is recommended:
2121

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

158158
---
159159

160+
## b2c code download
161+
162+
Download cartridge code from a B2C Commerce instance.
163+
164+
This command triggers server-side zipping of the code version, downloads the archive, and extracts cartridges locally. It is the inverse of `code deploy`.
165+
166+
### Usage
167+
168+
```bash
169+
b2c code download [CARTRIDGEPATH]
170+
```
171+
172+
### Arguments
173+
174+
| Argument | Description | Default |
175+
|----------|-------------|---------|
176+
| `CARTRIDGEPATH` | Path to search for local cartridges (used with `--mirror`) | `.` (current directory) |
177+
178+
### Flags
179+
180+
In addition to [global flags](./index#global-flags):
181+
182+
| Flag | Description | Default |
183+
|------|-------------|---------|
184+
| `--output`, `-o` | Output directory for downloaded cartridges | `cartridges` |
185+
| `--mirror`, `-m` | Extract cartridges to their local project locations | `false` |
186+
| `--cartridge`, `-c` | Include specific cartridge(s) (can be repeated) | |
187+
| `--exclude-cartridge`, `-x` | Exclude specific cartridge(s) (can be repeated) | |
188+
189+
### Examples
190+
191+
```bash
192+
# Download all cartridges from the active code version
193+
b2c code download --server my-sandbox.demandware.net
194+
195+
# Download to a specific output directory
196+
b2c code download -o ./downloaded
197+
198+
# Download a specific code version
199+
b2c code download --server my-sandbox.demandware.net --code-version v1
200+
201+
# Download only specific cartridges
202+
b2c code download -c app_storefront_base -c plugin_applepay
203+
204+
# Exclude certain cartridges
205+
b2c code download -x test_cartridge -x int_debug
206+
207+
# Mirror: extract to local cartridge project locations
208+
b2c code download --mirror
209+
210+
# Using environment variables
211+
export SFCC_SERVER=my-sandbox.demandware.net
212+
export SFCC_CODE_VERSION=v1
213+
export SFCC_USERNAME=my-user
214+
export SFCC_PASSWORD=my-access-key
215+
b2c code download -o ./backup
216+
```
217+
218+
### Mirror Mode
219+
220+
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.
221+
222+
If a cartridge exists remotely but not locally, it is extracted to the output directory as a fallback.
223+
224+
### Notes
225+
226+
- If no `--code-version` is specified, the command auto-discovers the active code version via OCAPI (requires OAuth credentials)
227+
- Existing file permissions are preserved when overwriting files
228+
- The server-side zip is cleaned up automatically after download
229+
230+
---
231+
160232
## b2c code activate
161233

162234
Activate a code version on a B2C Commerce instance, or reload the current active version.

docs/cli/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Safety Mode operates at the HTTP layer and cannot be bypassed by command-line fl
6363

6464
### Instance Operations
6565

66-
- [Code Commands](./code) - Deploy cartridges and manage code versions
66+
- [Code Commands](./code) - Deploy, download, and manage code versions
6767
- [Job Commands](./jobs) - Execute and monitor jobs, import/export site archives
6868
- [Sites Commands](./sites) - List and manage sites
6969
- [WebDAV Commands](./webdav) - File operations on instance WebDAV

packages/b2c-cli/src/commands/code/deploy.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,23 @@ export default class CodeDeploy extends CartridgeCommand<typeof CodeDeploy> {
177177
}
178178

179179
// Upload cartridges
180-
await this.operations.uploadCartridges(this.instance, cartridges);
180+
const uploadPhaseLabels = {
181+
archiving: t('commands.code.deploy.archiving', 'Creating cartridge archive'),
182+
uploading: t('commands.code.deploy.uploading', 'Uploading archive'),
183+
unzipping: t('commands.code.deploy.unzipping', 'Unzipping on server'),
184+
cleanup: t('commands.code.deploy.cleanup', 'Cleaning up'),
185+
};
186+
await this.operations.uploadCartridges(this.instance, cartridges, {
187+
onProgress: (info) => {
188+
if (this.jsonEnabled()) return;
189+
const label = uploadPhaseLabels[info.phase];
190+
if (info.elapsedSeconds === 0) {
191+
this.log(` ${label}...`);
192+
} else {
193+
this.log(` ${label}... (${info.elapsedSeconds}s elapsed)`);
194+
}
195+
},
196+
});
181197

182198
// Optionally activate or reload code version
183199
let activated = false;
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {Flags} from '@oclif/core';
7+
import {
8+
downloadCartridges,
9+
getActiveCodeVersion,
10+
type DownloadResult,
11+
} from '@salesforce/b2c-tooling-sdk/operations/code';
12+
import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli';
13+
import {t, withDocs} from '../../i18n/index.js';
14+
15+
export default class CodeDownload extends CartridgeCommand<typeof CodeDownload> {
16+
static hiddenAliases = ['code:download'];
17+
18+
static args = {
19+
...CartridgeCommand.baseArgs,
20+
};
21+
22+
static description = withDocs(
23+
t('commands.code.download.description', 'Download cartridge code from a B2C Commerce instance'),
24+
'/cli/code.html#b2c-code-download',
25+
);
26+
27+
static enableJsonFlag = true;
28+
29+
static examples = [
30+
'<%= config.bin %> <%= command.id %>',
31+
'<%= config.bin %> <%= command.id %> -o ./downloaded',
32+
'<%= config.bin %> <%= command.id %> --server my-sandbox.demandware.net --code-version v1',
33+
'<%= config.bin %> <%= command.id %> --mirror',
34+
'<%= config.bin %> <%= command.id %> -c app_storefront_base -c plugin_applepay',
35+
'<%= config.bin %> <%= command.id %> -x test_cartridge',
36+
];
37+
38+
static flags = {
39+
...CartridgeCommand.baseFlags,
40+
...CartridgeCommand.cartridgeFlags,
41+
output: Flags.string({
42+
char: 'o',
43+
description: 'Output directory for downloaded cartridges',
44+
default: 'cartridges',
45+
exclusive: ['mirror'],
46+
}),
47+
mirror: Flags.boolean({
48+
char: 'm',
49+
description: 'Extract cartridges to their local project locations',
50+
default: false,
51+
exclusive: ['output'],
52+
}),
53+
};
54+
55+
protected operations = {
56+
downloadCartridges,
57+
getActiveCodeVersion,
58+
};
59+
60+
async run(): Promise<DownloadResult> {
61+
this.requireWebDavCredentials();
62+
63+
const hostname = this.resolvedConfig.values.hostname!;
64+
let version = this.resolvedConfig.values.codeVersion;
65+
66+
const needsOAuth = !version;
67+
if (needsOAuth && !this.hasOAuthCredentials()) {
68+
this.error(
69+
t(
70+
'commands.code.download.oauthRequired',
71+
'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',
72+
),
73+
);
74+
}
75+
76+
// If no code version specified, discover the active one
77+
if (!version) {
78+
this.warn(
79+
t('commands.code.download.noCodeVersion', 'No code version specified, discovering active code version...'),
80+
);
81+
const activeVersion = await this.operations.getActiveCodeVersion(this.instance);
82+
if (!activeVersion?.id) {
83+
this.error(
84+
t('commands.code.download.noActiveVersion', 'No active code version found. Specify one with --code-version.'),
85+
);
86+
}
87+
version = activeVersion.id;
88+
this.instance.config.codeVersion = version;
89+
}
90+
91+
// Build mirror map if --mirror flag is set
92+
let mirror: Map<string, string> | undefined;
93+
if (this.flags.mirror) {
94+
const cartridges = await this.findCartridgesWithProviders();
95+
if (cartridges.length === 0) {
96+
this.error(
97+
t('commands.code.download.noLocalCartridges', 'No local cartridges found in {{path}} for mirror mode', {
98+
path: this.cartridgePath,
99+
}),
100+
);
101+
}
102+
mirror = new Map(cartridges.map((c) => [c.name, c.src]));
103+
}
104+
105+
// Create lifecycle context
106+
const context = this.createContext('code:download', {
107+
cartridgePath: this.cartridgePath,
108+
hostname,
109+
codeVersion: version,
110+
mirror: this.flags.mirror,
111+
output: this.flags.output,
112+
...this.cartridgeOptions,
113+
});
114+
115+
// Run beforeOperation hooks
116+
const beforeResult = await this.runBeforeHooks(context);
117+
if (beforeResult.skip) {
118+
this.log(
119+
t('commands.code.download.skipped', 'Download skipped: {{reason}}', {
120+
reason: beforeResult.skipReason || 'skipped by plugin',
121+
}),
122+
);
123+
return {
124+
cartridges: [],
125+
codeVersion: version,
126+
outputDirectory: this.flags.output ?? 'cartridges',
127+
};
128+
}
129+
130+
this.log(
131+
t('commands.code.download.downloading', 'Downloading code version "{{version}}" from {{hostname}}...', {
132+
version,
133+
hostname,
134+
}),
135+
);
136+
137+
// Temporarily allow DELETE for zip cleanup
138+
const cleanupSafetyRule = this.safetyGuard.temporarilyAddRule({
139+
method: 'DELETE',
140+
path: '**/Cartridges/*.zip',
141+
action: 'allow',
142+
});
143+
144+
try {
145+
const phaseLabels = {
146+
zipping: t('commands.code.download.zipping', 'Archiving code version'),
147+
downloading: t('commands.code.download.downloadingZip', 'Downloading cartridges'),
148+
cleanup: t('commands.code.download.cleanup', 'Cleaning up'),
149+
extracting: t('commands.code.download.extracting', 'Extracting cartridges'),
150+
};
151+
152+
const result = await this.operations.downloadCartridges(this.instance, this.flags.output ?? 'cartridges', {
153+
include: this.cartridgeOptions.include,
154+
exclude: this.cartridgeOptions.exclude,
155+
mirror,
156+
onProgress: (info) => {
157+
if (this.jsonEnabled()) return;
158+
const label = phaseLabels[info.phase];
159+
if (info.elapsedSeconds === 0) {
160+
this.log(` ${label}...`);
161+
} else {
162+
this.log(
163+
t('commands.code.download.elapsed', ' {{label}}... ({{elapsed}}s elapsed)', {
164+
label,
165+
elapsed: String(info.elapsedSeconds),
166+
}),
167+
);
168+
}
169+
},
170+
});
171+
172+
this.log(
173+
t('commands.code.download.summary', 'Downloaded {{count}} cartridge(s) from version "{{codeVersion}}"', {
174+
count: result.cartridges.length,
175+
codeVersion: result.codeVersion,
176+
}),
177+
);
178+
179+
for (const name of result.cartridges) {
180+
this.log(` ${name}`);
181+
}
182+
183+
// Run afterOperation hooks with success
184+
await this.runAfterHooks(context, {
185+
success: true,
186+
duration: Date.now() - context.startTime,
187+
data: result,
188+
});
189+
190+
return result;
191+
} catch (error) {
192+
// Run afterOperation hooks with failure
193+
await this.runAfterHooks(context, {
194+
success: false,
195+
error: error instanceof Error ? error : new Error(String(error)),
196+
duration: Date.now() - context.startTime,
197+
});
198+
199+
if (error instanceof Error) {
200+
this.error(t('commands.code.download.failed', 'Download failed: {{message}}', {message: error.message}));
201+
}
202+
throw error;
203+
} finally {
204+
cleanupSafetyRule();
205+
}
206+
}
207+
}

packages/b2c-cli/src/i18n/locales/de.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ export const de = {
5353
complete: 'Deployment abgeschlossen',
5454
failed: 'Deployment fehlgeschlagen: {{message}}',
5555
},
56+
download: {
57+
description: 'Cartridge-Code von einer B2C Commerce-Instanz herunterladen',
58+
downloading: 'Lade Code-Version "{{version}}" von {{hostname}} herunter...',
59+
summary: '{{count}} Cartridge(s) von Version "{{codeVersion}}" heruntergeladen',
60+
failed: 'Download fehlgeschlagen: {{message}}',
61+
},
5662
},
5763
sandbox: {
5864
create: {

packages/b2c-cli/src/i18n/locales/en.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,30 @@ export const en = {
105105
deploying: 'Deploying {{path}} to {{hostname}} ({{version}})',
106106
noCodeVersion: 'No code version specified, discovering active code version...',
107107
noActiveVersion: 'No active code version found. Specify one with --code-version.',
108+
archiving: 'Creating cartridge archive...',
109+
uploading: 'Uploading archive...',
110+
unzipping: 'Unzipping on server...',
111+
cleanup: 'Cleaning up...',
108112
summary: 'Deployed {{count}} cartridge(s) to {{codeVersion}}',
109113
reloaded: 'Code version reloaded',
110114
failed: 'Deployment failed: {{message}}',
111115
},
116+
download: {
117+
description: 'Download cartridge code from a B2C Commerce instance',
118+
downloading: 'Downloading code version "{{version}}" from {{hostname}}...',
119+
noCodeVersion: 'No code version specified, discovering active code version...',
120+
noActiveVersion: 'No active code version found. Specify one with --code-version.',
121+
noLocalCartridges: 'No local cartridges found in {{path}} for mirror mode',
122+
oauthRequired:
123+
'No code version specified. OAuth credentials are required to auto-discover the active code version.',
124+
skipped: 'Download skipped: {{reason}}',
125+
zipping: 'Archiving code version',
126+
downloadingZip: 'Downloading cartridges',
127+
cleanup: 'Cleaning up',
128+
extracting: 'Extracting cartridges',
129+
summary: 'Downloaded {{count}} cartridge(s) from version "{{codeVersion}}"',
130+
failed: 'Download failed: {{message}}',
131+
},
112132
watch: {
113133
description: 'Watch cartridges and upload changes to an instance',
114134
starting: 'Starting watcher for {{path}}',

packages/b2c-cli/test/commands/code/deploy.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ describe('code deploy', () => {
8787
const result = await command.run();
8888

8989
expect(deleteStub.calledOnceWithExactly(instance, cartridges)).to.equal(true);
90-
expect(uploadStub.calledOnceWithExactly(instance, cartridges)).to.equal(true);
90+
expect(uploadStub.calledOnce).to.equal(true);
91+
expect(uploadStub.firstCall.args[0]).to.equal(instance);
92+
expect(uploadStub.firstCall.args[1]).to.equal(cartridges);
9193
expect(reloadStub.calledOnceWithExactly(instance, 'v1')).to.equal(true);
9294

9395
expect(result).to.deep.include({codeVersion: 'v1', activated: true, reloaded: true});

0 commit comments

Comments
 (0)