diff --git a/.changeset/add-code-download-command.md b/.changeset/add-code-download-command.md new file mode 100644 index 00000000..847b67e4 --- /dev/null +++ b/.changeset/add-code-download-command.md @@ -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 diff --git a/docs/cli/code.md b/docs/cli/code.md index 5fe0e975..a0beb2d2 100644 --- a/docs/cli/code.md +++ b/docs/cli/code.md @@ -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 @@ -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 @@ -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. diff --git a/docs/cli/index.md b/docs/cli/index.md index 229ab0fc..7df778db 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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 diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index 1254df7b..3e613528 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -177,7 +177,23 @@ export default class CodeDeploy extends CartridgeCommand { } // 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; diff --git a/packages/b2c-cli/src/commands/code/download.ts b/packages/b2c-cli/src/commands/code/download.ts new file mode 100644 index 00000000..ce8910a5 --- /dev/null +++ b/packages/b2c-cli/src/commands/code/download.ts @@ -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 { + static hiddenAliases = ['code:download']; + + static args = { + ...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 { + 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 | 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(); + } + } +} diff --git a/packages/b2c-cli/src/i18n/locales/de.ts b/packages/b2c-cli/src/i18n/locales/de.ts index 1d7de174..40a0001a 100644 --- a/packages/b2c-cli/src/i18n/locales/de.ts +++ b/packages/b2c-cli/src/i18n/locales/de.ts @@ -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: { diff --git a/packages/b2c-cli/src/i18n/locales/en.ts b/packages/b2c-cli/src/i18n/locales/en.ts index 828a41e8..2ced47e6 100644 --- a/packages/b2c-cli/src/i18n/locales/en.ts +++ b/packages/b2c-cli/src/i18n/locales/en.ts @@ -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}}', diff --git a/packages/b2c-cli/test/commands/code/deploy.test.ts b/packages/b2c-cli/test/commands/code/deploy.test.ts index db066717..171abd78 100644 --- a/packages/b2c-cli/test/commands/code/deploy.test.ts +++ b/packages/b2c-cli/test/commands/code/deploy.test.ts @@ -87,7 +87,9 @@ describe('code deploy', () => { const result = await command.run(); expect(deleteStub.calledOnceWithExactly(instance, cartridges)).to.equal(true); - expect(uploadStub.calledOnceWithExactly(instance, cartridges)).to.equal(true); + expect(uploadStub.calledOnce).to.equal(true); + expect(uploadStub.firstCall.args[0]).to.equal(instance); + expect(uploadStub.firstCall.args[1]).to.equal(cartridges); expect(reloadStub.calledOnceWithExactly(instance, 'v1')).to.equal(true); expect(result).to.deep.include({codeVersion: 'v1', activated: true, reloaded: true}); diff --git a/packages/b2c-cli/test/commands/code/download.test.ts b/packages/b2c-cli/test/commands/code/download.test.ts new file mode 100644 index 00000000..8ac4f717 --- /dev/null +++ b/packages/b2c-cli/test/commands/code/download.test.ts @@ -0,0 +1,208 @@ +/* + * 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 {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import CodeDownload from '../../../src/commands/code/download.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('code download', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(CodeDownload, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any) { + const instance = {config: {hostname: 'example.com', codeVersion: 'v1'}}; + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'hasOAuthCredentials').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'warn').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: 'v1'}})); + sinon.stub(command, 'instance').get(() => instance); + return instance; + } + + it('runs before hooks and returns early when skipped', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: true, skipReason: 'by plugin'}); + sinon.stub(command, 'runAfterHooks').rejects(new Error('Unexpected after hooks')); + + const result = await command.run(); + + expect(result).to.deep.equal({cartridges: [], codeVersion: 'v1', outputDirectory: 'cartridges'}); + }); + + it('calls downloadCartridges with correct arguments', async () => { + const command: any = await createCommand({output: './out'}, {cartridgePath: '.'}); + const instance = stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const downloadResult = {cartridges: ['app_storefront'], codeVersion: 'v1', outputDirectory: '/tmp/out'}; + const downloadStub = sinon.stub().resolves(downloadResult); + command.operations = {...command.operations, downloadCartridges: downloadStub}; + + const result = await command.run(); + + expect(downloadStub.calledOnce).to.equal(true); + expect(downloadStub.firstCall.args[0]).to.equal(instance); + expect(downloadStub.firstCall.args[1]).to.equal('./out'); + expect(downloadStub.firstCall.args[2]).to.have.property('include'); + expect(downloadStub.firstCall.args[2]).to.have.property('exclude'); + expect(result).to.deep.equal(downloadResult); + }); + + it('passes cartridge filter flags through to options', async () => { + const command: any = await createCommand( + {cartridge: ['app_storefront', 'app_core'], 'exclude-cartridge': ['test_cart']}, + {cartridgePath: '.'}, + ); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const downloadResult = { + cartridges: ['app_storefront', 'app_core'], + codeVersion: 'v1', + outputDirectory: 'cartridges', + }; + const downloadStub = sinon.stub().resolves(downloadResult); + command.operations = {...command.operations, downloadCartridges: downloadStub}; + + await command.run(); + + const options = downloadStub.firstCall.args[2]; + expect(options.include).to.deep.equal(['app_storefront', 'app_core']); + expect(options.exclude).to.deep.equal(['test_cart']); + }); + + it('builds mirror map when --mirror flag is set', async () => { + const command: any = await createCommand({mirror: true}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + sinon + .stub(command, 'findCartridgesWithProviders') + .resolves([{name: 'app_storefront', src: '/project/app_storefront', dest: 'app_storefront'}]); + + const downloadResult = {cartridges: ['app_storefront'], codeVersion: 'v1', outputDirectory: 'cartridges'}; + const downloadStub = sinon.stub().resolves(downloadResult); + command.operations = {...command.operations, downloadCartridges: downloadStub}; + + await command.run(); + + const options = downloadStub.firstCall.args[2]; + expect(options.mirror).to.be.instanceOf(Map); + expect(options.mirror.get('app_storefront')).to.equal('/project/app_storefront'); + }); + + it('errors when no code version and no OAuth credentials', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'hasOAuthCredentials').returns(false); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'warn').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('OAuth required')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + const errorMessage = errorStub.firstCall.args[0]; + expect(errorMessage).to.include('auto-discover'); + }); + + it('uses active code version when codeVersion is not set', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'hasOAuthCredentials').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'warn').returns(void 0); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + + const instanceConfig: any = {hostname: 'example.com', codeVersion: undefined}; + const instance = {config: instanceConfig}; + sinon.stub(command, 'instance').get(() => instance); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const activeStub = sinon.stub().resolves({id: 'active_v', active: true}); + const downloadResult = {cartridges: ['c1'], codeVersion: 'active_v', outputDirectory: 'cartridges'}; + const downloadStub = sinon.stub().resolves(downloadResult); + command.operations = {...command.operations, getActiveCodeVersion: activeStub, downloadCartridges: downloadStub}; + + const result = await command.run(); + + expect(activeStub.calledOnce).to.equal(true); + expect(instanceConfig.codeVersion).to.equal('active_v'); + expect(result.codeVersion).to.equal('active_v'); + }); + + it('calls afterHooks on failure', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + const afterHooksStub = sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const downloadStub = sinon.stub().rejects(new Error('download failed')); + command.operations = {...command.operations, downloadCartridges: downloadStub}; + + sinon.stub(command, 'error').throws(new Error('Expected')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(afterHooksStub.calledOnce).to.equal(true); + const [, result] = afterHooksStub.firstCall.args; + expect(result.success).to.equal(false); + expect(result.error.message).to.equal('download failed'); + }); + + it('errors when --mirror set but no local cartridges found', async () => { + const command: any = await createCommand({mirror: true}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'findCartridgesWithProviders').resolves([]); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('mirror'); + }); +}); diff --git a/packages/b2c-tooling-sdk/src/cli/lifecycle.ts b/packages/b2c-tooling-sdk/src/cli/lifecycle.ts index c7fd2cf7..f16418b9 100644 --- a/packages/b2c-tooling-sdk/src/cli/lifecycle.ts +++ b/packages/b2c-tooling-sdk/src/cli/lifecycle.ts @@ -50,6 +50,7 @@ export type B2COperationType = | 'job:import' | 'job:export' | 'code:deploy' + | 'code:download' | 'code:activate' | 'site-archive:import' | 'site-archive:export'; diff --git a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts index 7c95da41..2116687a 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts @@ -16,6 +16,22 @@ const UNZIP_BODY = new URLSearchParams({method: 'UNZIP'}).toString(); /** * Options for deploying cartridges. */ +/** Progress info passed to the upload onProgress callback. */ +export interface UploadProgressInfo { + /** Current operation phase */ + phase: 'archiving' | 'uploading' | 'unzipping' | 'cleanup'; + /** Seconds elapsed since the current phase started */ + elapsedSeconds: number; +} + +/** + * Options for upload progress reporting. + */ +export interface UploadOptions { + /** Callback for progress updates. Called when a phase starts (elapsedSeconds=0) and periodically thereafter. */ + onProgress?: (info: UploadProgressInfo) => void; +} + export interface DeployOptions extends FindCartridgesOptions { /** Activate the code version after deploy */ activate?: boolean; @@ -23,6 +39,8 @@ export interface DeployOptions extends FindCartridgesOptions { reload?: boolean; /** Delete existing cartridges before uploading */ delete?: boolean; + /** Callback for progress updates during long-running operations */ + onProgress?: UploadOptions['onProgress']; } /** @@ -125,7 +143,11 @@ export async function deleteCartridges(instance: B2CInstance, cartridges: Cartri * await uploadCartridges(instance, cartridges); * ``` */ -export async function uploadCartridges(instance: B2CInstance, cartridges: CartridgeMapping[]): Promise { +export async function uploadCartridges( + instance: B2CInstance, + cartridges: CartridgeMapping[], + options?: UploadOptions, +): Promise { const logger = getLogger(); const codeVersion = instance.config.codeVersion; @@ -140,8 +162,22 @@ export async function uploadCartridges(instance: B2CInstance, cartridges: Cartri const webdav = instance.webdav; const now = Date.now(); const uploadPath = `Cartridges/_sync-${now}.zip`; + const onProgress = options?.onProgress; + + // Progress helper: fires immediately (0s) then every 5s until stopped + const PROGRESS_INTERVAL_MS = 5_000; + function startProgress(phase: UploadProgressInfo['phase']): () => void { + const start = Date.now(); + onProgress?.({phase, elapsedSeconds: 0}); + if (!onProgress) return () => {}; + const interval = setInterval(() => { + onProgress({phase, elapsedSeconds: Math.round((Date.now() - start) / 1000)}); + }, PROGRESS_INTERVAL_MS); + return () => clearInterval(interval); + } // Create zip archive + onProgress?.({phase: 'archiving', elapsedSeconds: 0}); logger.debug('Creating cartridge archive...'); const zip = new JSZip(); @@ -157,11 +193,14 @@ export async function uploadCartridges(instance: B2CInstance, cartridges: Cartri logger.debug({size: buffer.length}, `Archive created: ${buffer.length} bytes`); // Upload archive + let stopProgress = startProgress('uploading'); logger.debug({uploadPath}, 'Uploading archive...'); await webdav.put(uploadPath, buffer, 'application/zip'); + stopProgress(); logger.debug('Archive uploaded'); // Unzip on server + stopProgress = startProgress('unzipping'); logger.debug('Unzipping archive on server...'); const response = await webdav.request(uploadPath, { method: 'POST', @@ -170,6 +209,7 @@ export async function uploadCartridges(instance: B2CInstance, cartridges: Cartri 'Content-Type': 'application/x-www-form-urlencoded', }, }); + stopProgress(); if (!response.ok) { const text = await response.text(); @@ -178,6 +218,7 @@ export async function uploadCartridges(instance: B2CInstance, cartridges: Cartri logger.debug('Archive unzipped'); // Delete temporary archive (best-effort cleanup) + onProgress?.({phase: 'cleanup', elapsedSeconds: 0}); try { await webdav.delete(uploadPath); logger.debug('Temporary archive deleted'); @@ -261,7 +302,7 @@ export async function findAndDeployCartridges( } // Upload cartridges - await uploadCartridges(instance, cartridges); + await uploadCartridges(instance, cartridges, {onProgress: options.onProgress}); // Optionally activate or reload let activated = false; diff --git a/packages/b2c-tooling-sdk/src/operations/code/download.ts b/packages/b2c-tooling-sdk/src/operations/code/download.ts new file mode 100644 index 00000000..0b6a45e0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/download.ts @@ -0,0 +1,243 @@ +/* + * 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 path from 'node:path'; +import fs from 'node:fs'; +import JSZip from 'jszip'; +import type {B2CInstance} from '../../instance/index.js'; +import {getLogger} from '../../logging/logger.js'; +import {getActiveCodeVersion} from './versions.js'; + +const ZIP_BODY = new URLSearchParams({method: 'ZIP'}).toString(); + +// 10 minutes — server-side zipping and large downloads can take a long time +const LONG_OPERATION_TIMEOUT_MS = 600_000; + +/** Progress info passed to the onProgress callback. */ +export interface DownloadProgressInfo { + /** Current operation phase */ + phase: 'zipping' | 'downloading' | 'cleanup' | 'extracting'; + /** Seconds elapsed since the current phase started */ + elapsedSeconds: number; +} + +/** + * Options for downloading cartridges. + */ +export interface DownloadOptions { + /** Cartridge names to include (if empty/undefined, all are included) */ + include?: string[]; + /** Cartridge names to exclude */ + exclude?: string[]; + /** When provided, maps cartridge names to local paths for mirror extraction */ + mirror?: Map; + /** Callback for progress updates. Called when a phase starts (elapsedSeconds=0) and periodically thereafter. */ + onProgress?: (info: DownloadProgressInfo) => void; +} + +/** + * Result of a cartridge download. + */ +export interface DownloadResult { + /** Cartridge names that were extracted */ + cartridges: string[]; + /** Code version downloaded from */ + codeVersion: string; + /** Output directory where cartridges were extracted (empty string if mirror-only) */ + outputDirectory: string; +} + +/** + * Downloads cartridges from an instance via WebDAV. + * + * This function: + * 1. Triggers server-side zipping of the code version + * 2. Downloads the zip archive + * 3. Cleans up the server-side zip (best-effort) + * 4. Extracts cartridges locally with optional filtering + * + * If `instance.config.codeVersion` is not set, attempts to discover the active + * code version via OCAPI. If that also fails, throws an error. + * + * @param instance - B2C instance to download from + * @param outputDirectory - Directory to extract cartridges into + * @param options - Download options (filters, mirror) + * @returns Download result with cartridge names and metadata + * @throws Error if code version cannot be determined or download fails + * + * @example + * ```typescript + * // Download all cartridges + * const result = await downloadCartridges(instance, './output'); + * + * // Download specific cartridges + * const result = await downloadCartridges(instance, './output', { + * include: ['app_storefront_base'], + * }); + * + * // Mirror to local project locations + * const mirror = new Map([['app_storefront_base', '/project/cartridges/app_storefront_base']]); + * const result = await downloadCartridges(instance, '.', { mirror }); + * ``` + */ +export async function downloadCartridges( + instance: B2CInstance, + outputDirectory: string, + options: DownloadOptions = {}, +): Promise { + const logger = getLogger(); + let codeVersion = instance.config.codeVersion; + + if (!codeVersion) { + logger.debug('No code version configured, attempting to discover active version...'); + try { + const activeVersion = await getActiveCodeVersion(instance); + if (activeVersion?.id) { + codeVersion = activeVersion.id; + instance.config.codeVersion = codeVersion; + } + } catch (error) { + logger.debug({error}, 'Failed to discover active code version'); + } + if (!codeVersion) { + throw new Error( + 'Code version required for download. Configure --code-version or ensure OAuth credentials are available for auto-discovery.', + ); + } + } + + const webdav = instance.webdav; + const zipPath = `Cartridges/${codeVersion}.zip`; + const resolvedOutput = path.resolve(outputDirectory); + const {onProgress} = options; + + // Progress helper: fires immediately (0s) then every 5s until stopped + const PROGRESS_INTERVAL_MS = 5_000; + function startProgress(phase: DownloadProgressInfo['phase']): () => void { + const start = Date.now(); + onProgress?.({phase, elapsedSeconds: 0}); + if (!onProgress) return () => {}; + const interval = setInterval(() => { + onProgress({phase, elapsedSeconds: Math.round((Date.now() - start) / 1000)}); + }, PROGRESS_INTERVAL_MS); + return () => clearInterval(interval); + } + + // Step 1: Trigger server-side zip (can take several minutes for large code versions) + let stopProgress = startProgress('zipping'); + logger.debug({codeVersion}, 'Requesting server-side zip...'); + const zipResponse = await webdav.request(`Cartridges/${codeVersion}`, { + method: 'POST', + body: ZIP_BODY, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), + }); + stopProgress(); + + if (!zipResponse.ok) { + const text = await zipResponse.text(); + throw new Error(`Failed to create server-side zip: ${zipResponse.status} ${zipResponse.statusText} - ${text}`); + } + logger.debug('Server-side zip created'); + + // Step 2: Download zip archive (can be large) + stopProgress = startProgress('downloading'); + logger.debug({zipPath}, 'Downloading zip archive...'); + const downloadResponse = await webdav.request(zipPath, { + method: 'GET', + signal: AbortSignal.timeout(LONG_OPERATION_TIMEOUT_MS), + }); + + if (!downloadResponse.ok) { + stopProgress(); + throw new Error(`Failed to download zip: ${downloadResponse.status} ${downloadResponse.statusText}`); + } + + const buffer = await downloadResponse.arrayBuffer(); + stopProgress(); + logger.debug({size: buffer.byteLength}, `Archive downloaded: ${buffer.byteLength} bytes`); + + // Step 3: Cleanup server-side zip (best-effort) + onProgress?.({phase: 'cleanup', elapsedSeconds: 0}); + try { + await webdav.delete(zipPath); + logger.debug('Server-side zip cleaned up'); + } catch (error) { + logger.warn({error, zipPath}, 'Failed to clean up server-side zip (non-fatal)'); + } + + // Step 4: Extract locally + onProgress?.({phase: 'extracting', elapsedSeconds: 0}); + logger.debug('Extracting archive...'); + const zip = await JSZip.loadAsync(buffer); + const extractedCartridges = new Set(); + const {include, exclude, mirror} = options; + + const entries = Object.values(zip.files).filter((entry) => !entry.dir); + + for (const entry of entries) { + const parts = entry.name.split('/'); + if (parts.length < 3) { + continue; + } + + // Format: {codeVersion}/{cartridgeName}/{relativePath...} + parts.shift(); // remove codeVersion + const cartridgeName = parts.shift()!; + const relativePath = parts.join('/'); + + // Apply filters + if (include?.length && !include.includes(cartridgeName)) { + continue; + } + if (exclude?.length && exclude.includes(cartridgeName)) { + continue; + } + + let targetPath: string; + if (mirror?.has(cartridgeName)) { + targetPath = path.join(mirror.get(cartridgeName)!, relativePath); + } else { + targetPath = path.join(resolvedOutput, cartridgeName, relativePath); + } + + // Preserve existing file permissions + let existingMode: number | null = null; + try { + const stat = await fs.promises.stat(targetPath); + existingMode = stat.mode; + } catch { + // File doesn't exist yet + } + + // Ensure parent directory exists + await fs.promises.mkdir(path.dirname(targetPath), {recursive: true}); + + // Write file + const content = await entry.async('nodebuffer'); + await fs.promises.writeFile(targetPath, content); + + // Restore permissions if file existed + if (existingMode !== null) { + await fs.promises.chmod(targetPath, existingMode); + } + + extractedCartridges.add(cartridgeName); + } + + const cartridgeList = [...extractedCartridges].sort(); + logger.debug( + {server: instance.config.hostname, codeVersion, cartridgeCount: cartridgeList.length}, + `Downloaded ${cartridgeList.length} cartridge(s) from ${instance.config.hostname}`, + ); + + return { + cartridges: cartridgeList, + codeVersion, + outputDirectory: resolvedOutput, + }; +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/index.ts b/packages/b2c-tooling-sdk/src/operations/code/index.ts index 40a30baa..5701fbe4 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/index.ts @@ -29,6 +29,10 @@ * - {@link deleteCartridges} - Low-level cartridge deletion * - {@link watchCartridges} - Watch and sync file changes * + * ## Download + * + * - {@link downloadCartridges} - Download cartridges from an instance + * * ## Usage * * ```typescript @@ -79,7 +83,11 @@ export type {CodeVersion, CodeVersionResult} from './versions.js'; // Deployment export {findAndDeployCartridges, uploadCartridges, deleteCartridges} from './deploy.js'; -export type {DeployOptions, DeployResult} from './deploy.js'; +export type {DeployOptions, DeployResult, UploadOptions, UploadProgressInfo} from './deploy.js'; + +// Download +export {downloadCartridges} from './download.js'; +export type {DownloadOptions, DownloadProgressInfo, DownloadResult} from './download.js'; // Watch export {watchCartridges} from './watch.js'; diff --git a/packages/b2c-tooling-sdk/test/operations/code/download.test.ts b/packages/b2c-tooling-sdk/test/operations/code/download.test.ts new file mode 100644 index 00000000..d1920a23 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/code/download.test.ts @@ -0,0 +1,286 @@ +/* + * 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 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import JSZip from 'jszip'; +import {WebDavClient} from '../../../src/clients/webdav.js'; +import {createOcapiClient} from '../../../src/clients/ocapi.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import {downloadCartridges} from '../../../src/operations/code/download.js'; + +const TEST_HOST = 'test.demandware.net'; +const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; +const OCAPI_BASE = `https://${TEST_HOST}/s/-/dw/data/v25_6`; + +async function createTestZip(codeVersion: string, cartridges: Record>): Promise { + const zip = new JSZip(); + for (const [cartridgeName, files] of Object.entries(cartridges)) { + for (const [filePath, content] of Object.entries(files)) { + zip.file(`${codeVersion}/${cartridgeName}/${filePath}`, content); + } + } + return zip.generateAsync({type: 'nodebuffer'}); +} + +describe('operations/code/download', () => { + const server = setupServer(); + let mockInstance: any; + let tempDir: string; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-download-')); + + const auth = new MockAuthStrategy(); + const webdav = new WebDavClient(TEST_HOST, auth); + const ocapi = createOcapiClient(TEST_HOST, auth); + + mockInstance = { + config: { + hostname: TEST_HOST, + codeVersion: 'v1', + }, + webdav, + ocapi, + }; + }); + + afterEach(() => { + server.resetHandlers(); + if (tempDir) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + after(() => { + server.close(); + }); + + describe('downloadCartridges', () => { + it('should download and extract cartridges', async () => { + const zipBuffer = await createTestZip('v1', { + app_storefront: { + 'cartridge/scripts/main.js': 'console.log("hello");', + 'cartridge/templates/page.isml': '
', + }, + app_core: {'cartridge/scripts/core.js': 'module.exports = {};'}, + }); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + const url = new URL(request.url); + if (request.method === 'POST' && url.pathname.includes('/Cartridges/v1')) { + return new HttpResponse(null, {status: 204}); + } + if (request.method === 'GET' && url.pathname.endsWith('/Cartridges/v1.zip')) { + return new HttpResponse(zipBuffer, {status: 200, headers: {'Content-Type': 'application/zip'}}); + } + if (request.method === 'DELETE' && url.pathname.endsWith('/Cartridges/v1.zip')) { + return new HttpResponse(null, {status: 204}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + const result = await downloadCartridges(mockInstance, tempDir); + + expect(result.cartridges).to.have.members(['app_core', 'app_storefront']); + expect(result.codeVersion).to.equal('v1'); + + const mainJs = fs.readFileSync(path.join(tempDir, 'app_storefront/cartridge/scripts/main.js'), 'utf-8'); + expect(mainJs).to.equal('console.log("hello");'); + + const coreJs = fs.readFileSync(path.join(tempDir, 'app_core/cartridge/scripts/core.js'), 'utf-8'); + expect(coreJs).to.equal('module.exports = {};'); + }); + + it('should apply include filter', async () => { + const zipBuffer = await createTestZip('v1', { + app_storefront: {'main.js': 'storefront'}, + app_core: {'core.js': 'core'}, + }); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'POST') return new HttpResponse(null, {status: 204}); + if (request.method === 'GET') return new HttpResponse(zipBuffer, {status: 200}); + if (request.method === 'DELETE') return new HttpResponse(null, {status: 204}); + return new HttpResponse(null, {status: 404}); + }), + ); + + const result = await downloadCartridges(mockInstance, tempDir, {include: ['app_storefront']}); + + expect(result.cartridges).to.deep.equal(['app_storefront']); + expect(fs.existsSync(path.join(tempDir, 'app_storefront/main.js'))).to.be.true; + expect(fs.existsSync(path.join(tempDir, 'app_core/core.js'))).to.be.false; + }); + + it('should apply exclude filter', async () => { + const zipBuffer = await createTestZip('v1', { + app_storefront: {'main.js': 'storefront'}, + app_core: {'core.js': 'core'}, + }); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'POST') return new HttpResponse(null, {status: 204}); + if (request.method === 'GET') return new HttpResponse(zipBuffer, {status: 200}); + if (request.method === 'DELETE') return new HttpResponse(null, {status: 204}); + return new HttpResponse(null, {status: 404}); + }), + ); + + const result = await downloadCartridges(mockInstance, tempDir, {exclude: ['app_core']}); + + expect(result.cartridges).to.deep.equal(['app_storefront']); + expect(fs.existsSync(path.join(tempDir, 'app_storefront/main.js'))).to.be.true; + expect(fs.existsSync(path.join(tempDir, 'app_core'))).to.be.false; + }); + + it('should extract to mirror paths when mirror map is provided', async () => { + const mirrorDir = path.join(tempDir, 'mirror_target'); + fs.mkdirSync(mirrorDir, {recursive: true}); + + const zipBuffer = await createTestZip('v1', { + app_storefront: {'scripts/main.js': 'mirrored'}, + }); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'POST') return new HttpResponse(null, {status: 204}); + if (request.method === 'GET') return new HttpResponse(zipBuffer, {status: 200}); + if (request.method === 'DELETE') return new HttpResponse(null, {status: 204}); + return new HttpResponse(null, {status: 404}); + }), + ); + + const mirror = new Map([['app_storefront', mirrorDir]]); + const result = await downloadCartridges(mockInstance, tempDir, {mirror}); + + expect(result.cartridges).to.deep.equal(['app_storefront']); + const content = fs.readFileSync(path.join(mirrorDir, 'scripts/main.js'), 'utf-8'); + expect(content).to.equal('mirrored'); + }); + + it('should throw error when code version cannot be determined', async () => { + mockInstance.config.codeVersion = undefined; + + server.use( + http.get(`${OCAPI_BASE}/code_versions`, () => { + return HttpResponse.json({data: [{id: 'v1', active: false}]}); + }), + ); + + try { + await downloadCartridges(mockInstance, tempDir); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Code version required'); + } + }); + + it('should auto-discover code version when not set', async () => { + mockInstance.config.codeVersion = undefined; + + const zipBuffer = await createTestZip('active_v', { + app_storefront: {'main.js': 'content'}, + }); + + server.use( + http.get(`${OCAPI_BASE}/code_versions`, () => { + return HttpResponse.json({data: [{id: 'active_v', active: true}]}); + }), + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'POST') return new HttpResponse(null, {status: 204}); + if (request.method === 'GET') return new HttpResponse(zipBuffer, {status: 200}); + if (request.method === 'DELETE') return new HttpResponse(null, {status: 204}); + return new HttpResponse(null, {status: 404}); + }), + ); + + const result = await downloadCartridges(mockInstance, tempDir); + + expect(result.codeVersion).to.equal('active_v'); + expect(result.cartridges).to.deep.equal(['app_storefront']); + }); + + it('should handle server-side zip failure', async () => { + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'POST') { + return new HttpResponse('Internal Server Error', {status: 500}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + try { + await downloadCartridges(mockInstance, tempDir); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to create server-side zip'); + } + }); + + it('should handle cleanup failure gracefully', async () => { + const zipBuffer = await createTestZip('v1', { + app_storefront: {'main.js': 'content'}, + }); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'POST') return new HttpResponse(null, {status: 204}); + if (request.method === 'GET') return new HttpResponse(zipBuffer, {status: 200}); + if (request.method === 'DELETE') return new HttpResponse('Server Error', {status: 500}); + return new HttpResponse(null, {status: 404}); + }), + ); + + // Should succeed even though cleanup fails + const result = await downloadCartridges(mockInstance, tempDir); + expect(result.cartridges).to.deep.equal(['app_storefront']); + }); + + it('should preserve existing file permissions', async () => { + // Create a file with specific permissions + const cartridgeDir = path.join(tempDir, 'app_storefront'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + const filePath = path.join(cartridgeDir, 'main.js'); + fs.writeFileSync(filePath, 'old content'); + fs.chmodSync(filePath, 0o755); + + const zipBuffer = await createTestZip('v1', { + app_storefront: {'main.js': 'new content'}, + }); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'POST') return new HttpResponse(null, {status: 204}); + if (request.method === 'GET') return new HttpResponse(zipBuffer, {status: 200}); + if (request.method === 'DELETE') return new HttpResponse(null, {status: 204}); + return new HttpResponse(null, {status: 404}); + }), + ); + + await downloadCartridges(mockInstance, tempDir); + + const stat = fs.statSync(filePath); + // eslint-disable-next-line no-bitwise + expect(stat.mode & 0o777).to.equal(0o755); + expect(fs.readFileSync(filePath, 'utf-8')).to.equal('new content'); + }); + }); +}); diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index 57f8d1d2..1712b54b 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -1,11 +1,11 @@ --- name: b2c-code -description: Deploy cartridge code and manage code versions on B2C Commerce instances. Use this skill whenever the user needs to upload cartridges to a sandbox, activate or delete code versions, watch for local file changes during development, or deploy a subset of cartridges. Also use when pushing code to an instance or setting up a dev workflow with live reload -- even if they just say 'push my code to the sandbox' or 'how do I activate the new version'. +description: Deploy, download, and manage cartridge code versions on B2C Commerce instances. Use this skill whenever the user needs to upload or download cartridges to/from a sandbox, activate or delete code versions, watch for local file changes during development, or deploy a subset of cartridges. Also use when pushing code to an instance, pulling code from an instance, or setting up a dev workflow with live reload -- even if they just say 'push my code to the sandbox', 'download the code', or 'how do I activate the new version'. --- # B2C Code Skill -Use the `b2c` CLI to deploy and manage code versions on Salesforce B2C Commerce instances. +Use the `b2c` CLI to deploy, download, and manage code versions on Salesforce B2C Commerce instances. > **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli code deploy`). @@ -36,6 +36,28 @@ b2c code deploy -c app_storefront_base -c plugin_applepay b2c code deploy -x test_cartridge ``` +### Download Cartridges + +```bash +# download all cartridges from the active code version +b2c code download + +# download to a specific directory +b2c code download -o ./downloaded + +# download from a specific server and 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 specific cartridges from download +b2c code download -x test_cartridge + +# mirror: extract to local cartridge project locations +b2c code download --mirror +``` + ### Watch for Changes ```bash diff --git a/skills/b2c-cli/skills/b2c-code/evals/trigger-evals.json b/skills/b2c-cli/skills/b2c-code/evals/trigger-evals.json index 7b4845ea..a4b81b2f 100644 --- a/skills/b2c-cli/skills/b2c-code/evals/trigger-evals.json +++ b/skills/b2c-cli/skills/b2c-code/evals/trigger-evals.json @@ -5,6 +5,8 @@ {"query": "I just finished building a new cartridge and need to push it to my sandbox for testing. What's the fastest way?", "should_trigger": true}, {"query": "Our release process requires deploying cartridges to staging, activating the new version, then verifying. Walk me through that workflow.", "should_trigger": true}, {"query": "I have a monorepo with multiple cartridges. How do I deploy a subset of them without uploading everything?", "should_trigger": true}, + {"query": "I need to download the cartridge code from my sandbox to see what's currently deployed. How do I pull the code down?", "should_trigger": true}, + {"query": "How do I sync the remote code version back to my local project? I want to mirror what's on the instance.", "should_trigger": true}, {"query": "I need to upload a site import archive to the IMPEX directory on my instance. What's the right approach?", "should_trigger": false}, {"query": "My ISML templates have a bug where isprint is double-encoding HTML entities. How do I fix the encoding attribute?", "should_trigger": false}, {"query": "How do I tail the error logs on my sandbox to debug a 500 error on the storefront?", "should_trigger": false},