diff --git a/.changeset/cap-commerce-app-packages.md b/.changeset/cap-commerce-app-packages.md new file mode 100644 index 00000000..f175a6b9 --- /dev/null +++ b/.changeset/cap-commerce-app-packages.md @@ -0,0 +1,17 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +'b2c-vs-extension': minor +--- + +Add `cap` command topic for Commerce App Package (CAP) management. + +New commands: +- `b2c cap validate` — validates CAP structure, manifest, and cartridge rules locally +- `b2c cap package` — packages a CAP directory into a distributable `.zip` +- `b2c cap install` — installs a CAP on an instance via WebDAV + `sfcc-install-commerce-app` job +- `b2c cap uninstall` — uninstalls a CAP via `sfcc-uninstall-commerce-app` job + +New SDK exports in `@salesforce/b2c-tooling-sdk/operations/cap`: `validateCap`, `commerceAppInstall`, `commerceAppUninstall`, `commerceAppPackage`. + +The VS Code extension gains CAP directory detection (badge decoration) and an "Install Commerce App (CAP)" context menu action. diff --git a/.changeset/cap-support-commands.md b/.changeset/cap-support-commands.md new file mode 100644 index 00000000..7a4cc41d --- /dev/null +++ b/.changeset/cap-support-commands.md @@ -0,0 +1,14 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-dx-docs': patch +--- + +Add `cap list`, `cap tasks`, and `cap pull` commands for managing installed Commerce Apps + +- `cap list` exports and parses `commerce_feature_states` to show installed features with type, source, status, and version +- `cap tasks` displays configuration tasks for an installed app with clickable Business Manager links +- `cap pull` downloads and extracts installed app source packages for cartridge deployment or Storefront Next development +- Standardize all cap commands to use `--site-id` flag (with `--site` as alias) +- `cap uninstall` no longer requires `--domain` — looks it up automatically from the feature state +- `cap install` now keeps the archive on the instance by default (use `--clean-archive` to remove) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e60ef911..53ad839a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -73,6 +73,7 @@ const guidesSidebar = [ {text: 'Security', link: '/guide/security'}, {text: 'Storefront Next', link: '/guide/storefront-next'}, {text: 'MRT Utilities', link: '/guide/mrt-utilities'}, + {text: 'Commerce Apps (CAPs)', link: '/guide/commerce-apps'}, ], }, { @@ -103,6 +104,7 @@ const referenceSidebar = [ {text: 'Auth', link: '/cli/auth'}, {text: 'BM Roles', link: '/cli/bm-roles'}, {text: 'CIP', link: '/cli/cip'}, + {text: 'CAP (Commerce Apps)', link: '/cli/cap'}, {text: 'Code', link: '/cli/code'}, {text: 'Content', link: '/cli/content'}, {text: 'Custom APIs', link: '/cli/custom-apis'}, diff --git a/docs/cli/cap.md b/docs/cli/cap.md new file mode 100644 index 00000000..f5298076 --- /dev/null +++ b/docs/cli/cap.md @@ -0,0 +1,347 @@ +--- +description: Commands for validating, packaging, installing, uninstalling, and listing Commerce App Packages (CAPs) and commerce features. +--- + +# Commerce App (CAP) Commands + +Commands for managing Commerce App Packages (CAPs) — the standard format for distributing B2C Commerce integrations — and listing commerce features installed on an instance. + +## Overview + +A Commerce App Package bundles cartridges, IMPEX data, and Storefront Next extensions into a single installable unit. See the [Commerce Apps guide](/guide/commerce-apps) for full workflow details. + +The `cap list` and `cap tasks` commands work with the broader commerce feature state system, which tracks all installed features including ISV apps, native apps, native features, and custom features. + +## Authentication + +Install, uninstall, list, and tasks commands require OAuth authentication with OCAPI permissions and WebDAV access. + +### Required OCAPI Permissions + +Configure these resources in Business Manager under **Administration** > **Site Development** > **Open Commerce API Settings**: + +| Resource | Methods | Commands | +|----------|---------|----------| +| `/jobs/*/executions` | POST | `cap install`, `cap uninstall`, `cap list`, `cap tasks` | +| `/jobs/*/executions/*` | GET | `cap install`, `cap uninstall`, `cap list`, `cap tasks` | +| `/sites` | GET | `cap list` (when no `--site-id` specified) | + +### WebDAV Access + +The `cap install` command uploads the CAP zip to WebDAV (`Impex/commerce-apps/`) before triggering the install job. The `cap list`, `cap tasks`, and `cap uninstall` commands download site archive exports via WebDAV. + +--- + +## b2c cap validate + +Validate the structure and manifest of a Commerce App Package (CAP). + +### Usage + +```bash +b2c cap validate PATH +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `PATH` | Path to a CAP directory or `.zip` file | + +### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output result as JSON | + +### Validation Checks + +**Errors** (blocking — must fix before install): + +- `commerce-app.json` must exist and be valid JSON +- Manifest must include `id`, `name`, `version`, `domain` fields +- `version` must be a valid semver string +- `app-configuration/tasksList.json` must exist as a valid JSON array +- Each task must have `taskNumber`, `name`, `description`, `link` +- At least one of `cartridges/`, `storefront-next/`, or `impex/` must be present +- No `pipeline/` directories in cartridges +- No `*.ds` pipeline descriptor files +- Site cartridges (`cartridges/site_cartridges/`) must not contain `controllers/` +- `README.md` must exist + +**Warnings** (advisory): + +- `icons/icon.png` is recommended for marketplace listing +- `impex/uninstall/` is recommended for clean removal + +### Examples + +```bash +# Validate a local CAP directory +b2c cap validate ./commerce-avalara-tax-app-v0.2.5 + +# Validate a zipped CAP +b2c cap validate ./commerce-avalara-tax-app-v0.2.5.zip + +# Machine-readable output +b2c cap validate ./commerce-avalara-tax-app-v0.2.5 --json +``` + +--- + +## b2c cap package + +Package a Commerce App directory into a distributable `.zip` file. + +### Usage + +```bash +b2c cap package PATH [--output PATH] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `PATH` | Path to the CAP source directory | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--output PATH` | `-o` | Output path (directory or `.zip` filename). Defaults to current directory. | +| `--json` | | Output result as JSON | + +### Examples + +```bash +# Package to current directory +b2c cap package ./commerce-avalara-tax-app-v0.2.5 + +# Package to a specific output directory +b2c cap package ./commerce-avalara-tax-app-v0.2.5 --output ./dist + +# Package with explicit zip filename +b2c cap package ./commerce-avalara-tax-app-v0.2.5 --output ./dist/my-app.zip +``` + +--- + +## b2c cap install + +Install a Commerce App Package on a B2C Commerce instance. + +### Usage + +```bash +b2c cap install PATH --site-id SITE_ID +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `PATH` | Path to a CAP directory or `.zip` file | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--site-id SITE_ID` | `-s` | **Required.** Site ID to install the app on | +| `--clean-archive` | | Delete the uploaded zip from the instance after install | +| `--timeout SECONDS` | `-t` | Timeout in seconds (default: no timeout) | +| `--skip-validate` | | Skip CAP structure validation before install | +| `--json` | | Output result as JSON | + +### Install Process + +1. Validates the CAP structure (unless `--skip-validate`) +2. Packages the directory into a zip if a directory is provided +3. Uploads the zip to WebDAV at `Impex/commerce-apps/{id}-v{version}.zip` +4. Executes the `sfcc-install-commerce-app` system job +5. Waits for job completion +6. Archive is kept on the instance by default (use `--clean-archive` to remove) + +### Examples + +```bash +# Install from a local directory +b2c cap install ./commerce-avalara-tax-app-v0.2.5 --site-id RefArch + +# Install from a zip +b2c cap install ./commerce-avalara-tax-app-v0.2.5.zip --site-id RefArch + +# Install without running validation first +b2c cap install ./commerce-avalara-tax-app-v0.2.5 --site-id RefArch --skip-validate + +# Remove the uploaded archive after install +b2c cap install ./commerce-avalara-tax-app-v0.2.5 --site-id RefArch --clean-archive +``` + +--- + +## b2c cap uninstall + +Uninstall a Commerce App from a B2C Commerce instance. Looks up the app's domain automatically from the commerce feature state. + +### Usage + +```bash +b2c cap uninstall APP_NAME --site-id SITE_ID +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `APP_NAME` | App ID to uninstall (from `commerce-app.json` `"id"` field, e.g. `avalara-tax`) | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--site-id SITE_ID` | `-s` | **Required.** Site ID to uninstall the app from | +| `--timeout SECONDS` | `-t` | Timeout in seconds (default: no timeout) | +| `--json` | | Output result as JSON | + +### Examples + +```bash +# Uninstall Avalara Tax from a site +b2c cap uninstall avalara-tax --site-id RefArch +``` + +--- + +## b2c cap list + +List commerce features installed on a B2C Commerce instance. Exports the `commerce_feature_states` data unit from each site and parses the results. + +### Usage + +```bash +b2c cap list [--site-id SITE_IDS] +``` + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--site-id SITE_IDS` | `-s` | Site IDs to query (comma-separated). If omitted, queries all sites. | +| `--timeout SECONDS` | `-t` | Timeout in seconds (default: no timeout) | +| `--local` | `-l` | List locally detected Commerce App Packages (no instance required) | +| `--json` | | Output result as JSON | + +### Table Columns + +| Column | Description | +|--------|-------------| +| Site ID | Site the feature is installed on (includes `Sites-` prefix) | +| Name | Feature name (e.g. `avalara-tax`) | +| Type | `ISV_APP`, `NATIVE_APP`, `NATIVE_FEATURE`, or `CUSTOM_FEATURE` | +| Source | `CUSTOM` (uploaded via WebDAV) or `REGISTRY` (from App Registry) | +| Install Status | e.g. `INSTALLED` | +| Config Status | e.g. `NOT_CONFIGURED`, `CONFIGURED` | +| Version | Feature version if available | +| Installed At | Installation timestamp | + +### JSON Output + +With `--json`, returns the full feature state including `configTasks` (parsed JSON array of configuration steps) and `installationMetadata` (parsed JSON with job details, cartridge mappings, and IMPEX uninstall data). + +### Examples + +```bash +# List all installed features across all sites +b2c cap list + +# List features for specific sites +b2c cap list --site-id RefArch,SiteGenesis + +# Machine-readable output with full details +b2c cap list --json + +# List locally detected CAP directories +b2c cap list --local +``` + +--- + +## b2c cap tasks + +List configuration tasks for an installed Commerce App, with clickable links to Business Manager pages. + +### Usage + +```bash +b2c cap tasks APP_NAME --site-id SITE_ID +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `APP_NAME` | Commerce App feature name (e.g. `avalara-tax`) | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--site-id SITE_ID` | `-s` | **Required.** Site ID to query | +| `--timeout SECONDS` | `-t` | Timeout in seconds (default: no timeout) | +| `--json` | | Output result as JSON | + +### Examples + +```bash +# Show configuration tasks for an installed app +b2c cap tasks avalara-tax --site-id RefArch + +# Get tasks as JSON (includes full feature state) +b2c cap tasks avalara-tax --site-id RefArch --json +``` + +--- + +## b2c cap pull + +Pull installed Commerce App source packages from a B2C Commerce instance. By default, pulls all registry-sourced apps. Optionally specify a single app by name. + +Useful for deploying cartridges to a code version or working with Storefront Next extensions locally. + +### Usage + +```bash +b2c cap pull [APP_NAME] [--site-id SITE_ID] [--output DIR] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `APP_NAME` | *(optional)* Commerce App feature name to pull (e.g. `avalara-tax`). If omitted, pulls all registry apps. | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--site-id SITE_ID` | `-s` | Site ID to query for installed apps. If omitted, queries all sites. | +| `--output DIR` | `-o` | Output directory (default: `./commerce-apps`) | +| `--timeout SECONDS` | `-t` | Timeout in seconds (default: no timeout) | +| `--json` | | Output result as JSON | + +### Examples + +```bash +# Pull all registry apps to ./commerce-apps +b2c cap pull + +# Pull a specific app by name +b2c cap pull avalara-tax + +# Pull to a custom output directory +b2c cap pull --output ./my-apps + +# Pull apps installed on a specific site +b2c cap pull --site-id RefArch +``` diff --git a/docs/guide/commerce-apps.md b/docs/guide/commerce-apps.md new file mode 100644 index 00000000..d44ed3d7 --- /dev/null +++ b/docs/guide/commerce-apps.md @@ -0,0 +1,163 @@ +--- +description: How to validate, package, install, and uninstall Commerce App Packages (CAPs) with the B2C CLI and VS Code extension. +--- + +# Commerce Apps (CAPs) + +Commerce App Packages (CAPs) are the standard format for distributing B2C Commerce integrations. A CAP bundles cartridges, IMPEX configuration data, and Storefront Next UI extensions into a single installable unit. + +## What Is a Commerce App Package? + +A CAP can contain any combination of: + +- **Back-end cartridges** — site cartridges implementing extension points (e.g., `sf.commerce.app.tax.calculate`) and BM cartridges for admin UI +- **IMPEX data** — services, site preferences, custom object definitions, and uninstall cleanup scripts +- **Storefront Next extensions** — React components injected into the storefront via UI targets +- **App configuration** — `tasksList.json` defining the post-install setup wizard in Business Manager + +## CAP Directory Structure + +``` +commerce-{app}-v{version}/ +├── commerce-app.json # Required: app manifest (id, name, version, domain) +├── README.md # Required: documentation +├── app-configuration/ +│ └── tasksList.json # Required: post-install configuration wizard +├── cartridges/ +│ ├── bm_cartridges/ # Business Manager cartridges (may have controllers/) +│ └── site_cartridges/ # Site cartridges (no controllers/, no pipelines) +├── icons/ +│ └── icon.png # Recommended: marketplace icon +├── impex/ +│ ├── install/ # Data imported on install +│ └── uninstall/ # Recommended: cleanup data for removal +└── storefront-next/ + └── src/extensions/ # React components and target-config.json +``` + +## Development Workflow + +### 1. Build and Organize Your CAP + +Structure your project following the CAP layout above. The `commerce-app.json` manifest is required: + +```json +{ + "id": "my-integration", + "name": "My Integration", + "version": "1.0.0", + "domain": "tax", + "description": "Automated tax compliance", + "publisher": { + "name": "My Company", + "url": "https://example.com" + } +} +``` + +### 2. Validate + +Run validation before packaging or installing to catch structural issues early: + +```bash +b2c cap validate ./commerce-my-integration-v1.0.0 +``` + +Validation checks include: +- Required files (`commerce-app.json`, `README.md`, `tasksList.json`) +- Valid manifest schema and semver version +- No pipelines or `.ds` files (not supported in CAPs) +- Site cartridges must not have `controllers/` (use extension points instead) + +### 3. Package (Optional) + +Create a distributable zip for CI/CD pipelines or registry submission: + +```bash +b2c cap package ./commerce-my-integration-v1.0.0 --output ./dist +``` + +This creates `dist/my-integration-v1.0.0.zip` with the correct root directory structure. + +### 4. Install on a Sandbox + +Install the CAP on a sandbox instance for testing. Provide the local directory or zip: + +```bash +# From a directory +b2c cap install ./commerce-my-integration-v1.0.0 --site RefArch + +# From a zip +b2c cap install ./dist/my-integration-v1.0.0.zip --site RefArch +``` + +The CLI uploads the package to WebDAV (`Temp/`) and triggers the `sfcc-install-commerce-app` platform job, which deploys cartridges, imports IMPEX data, and creates a Storefront Next PR (if applicable). + +### 5. Complete Configuration Tasks + +After install, complete the setup wizard tasks in Business Manager or via CLI. Installed apps transition through: + +`INSTALLING` → `INSTALLED` → `CONFIGURING` → `ACTIVE` + +### 6. Uninstall + +To remove an installed app: + +```bash +b2c cap uninstall my-integration --domain tax --site RefArch +``` + +## VS Code Extension Integration + +The B2C DX VS Code extension provides Commerce App support directly in the file explorer. + +### CAP Directory Detection + +Any directory containing a `commerce-app.json` file is automatically decorated with a **CA** badge in the VS Code explorer, making CAP directories easy to identify. + +### Install from the Explorer + +Right-click any CAP directory in the VS Code explorer and choose **B2C-DX → Install Commerce App (CAP)**. You will be prompted for the target site ID. + +## Authentication Requirements + +Install and uninstall commands require: + +- **OAuth credentials** (`SFCC_CLIENT_ID`, `SFCC_CLIENT_SECRET`) — for running the system job +- **WebDAV credentials** (`SFCC_USERNAME`, `SFCC_PASSWORD`) — for uploading the CAP zip + +```bash +export SFCC_HOSTNAME=my-instance.commercecloud.salesforce.com +export SFCC_CLIENT_ID=your-client-id +export SFCC_CLIENT_SECRET=your-client-secret +export SFCC_USERNAME=your-bm-username +export SFCC_PASSWORD=your-webdav-access-key +``` + +See the [Authentication Guide](/guide/authentication) for complete setup instructions. + +## CI/CD Integration + +A typical CI/CD pipeline for Commerce App deployment: + +```yaml +- name: Validate CAP + run: b2c cap validate ./commerce-my-integration-v${{ env.VERSION }} + +- name: Package CAP + run: b2c cap package ./commerce-my-integration-v${{ env.VERSION }} --output ./dist + +- name: Install CAP + run: b2c cap install ./dist/my-integration-v${{ env.VERSION }}.zip --site ${{ env.SITE_ID }} + env: + SFCC_HOSTNAME: ${{ secrets.SFCC_HOSTNAME }} + SFCC_CLIENT_ID: ${{ secrets.SFCC_CLIENT_ID }} + SFCC_CLIENT_SECRET: ${{ secrets.SFCC_CLIENT_SECRET }} + SFCC_USERNAME: ${{ secrets.SFCC_USERNAME }} + SFCC_PASSWORD: ${{ secrets.SFCC_PASSWORD }} +``` + +## Reference + +- [CAP CLI Commands](/cli/cap) — full command reference +- [ISV Developer Guide](https://developer.salesforce.com) — CAP structure specification and domain extension points diff --git a/packages/b2c-cli/src/commands/cap/install.ts b/packages/b2c-cli/src/commands/cap/install.ts new file mode 100644 index 00000000..ff8dc2a8 --- /dev/null +++ b/packages/b2c-cli/src/commands/cap/install.ts @@ -0,0 +1,173 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + commerceAppInstall, + validateCap, + JobExecutionError, + type CommerceAppInstallResult, +} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {t, withDocs} from '../../i18n/index.js'; + +export default class CapInstall extends JobCommand { + static args = { + path: Args.string({ + description: 'Path to a CAP directory or .zip file', + required: true, + }), + }; + + static description = withDocs( + t('commands.cap.install.description', 'Install a Commerce App Package (CAP) on a B2C Commerce instance'), + '/cli/cap.html#b2c-cap-install', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5 --site RefArch', + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5.zip --site RefArch', + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5 --site RefArch --skip-validate', + ]; + + static flags = { + ...JobCommand.baseFlags, + 'site-id': Flags.string({ + char: 's', + description: 'Site ID to install the Commerce App on', + required: true, + aliases: ['site'], + }), + 'clean-archive': Flags.boolean({ + description: 'Delete the uploaded zip from the instance after install', + default: false, + }), + timeout: Flags.integer({ + char: 't', + description: 'Timeout in seconds (default: no timeout)', + }), + 'skip-validate': Flags.boolean({ + description: 'Skip CAP structure validation before install', + default: false, + }), + }; + + protected operations = { + commerceAppInstall, + validateCap, + }; + + async run(): Promise { + this.requireOAuthCredentials(); + this.requireWebDavCredentials(); + + const {path} = this.args; + const {'site-id': site, 'clean-archive': cleanArchive, timeout, 'skip-validate': skipValidate} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + // Validate first unless skipped + if (!skipValidate) { + this.log(t('commands.cap.install.validating', 'Validating CAP structure...')); + const validation = await this.operations.validateCap(path); + if (!validation.valid) { + for (const err of validation.errors) { + this.log(` ✗ ${err}`); + } + this.error( + t('commands.cap.install.validationFailed', 'CAP validation failed — use --skip-validate to bypass'), + {exit: 1}, + ); + } + if (validation.warnings.length > 0) { + for (const warn of validation.warnings) { + this.log(` ⚠ ${warn}`); + } + } + } + + this.log( + t('commands.cap.install.installing', 'Installing CAP {{path}} to {{hostname}} (site: {{site}})...', { + path, + hostname, + site, + }), + ); + + const context = this.createContext('cap:install', {path, site, hostname}); + const beforeResult = await this.runBeforeHooks(context); + if (beforeResult.skip) { + this.log( + t('commands.cap.install.skipped', 'Install skipped: {{reason}}', { + reason: beforeResult.skipReason || 'skipped by plugin', + }), + ); + return { + execution: {execution_status: 'finished', exit_status: {code: 'skipped'}}, + appName: '', + appVersion: '', + archiveFilename: '', + archiveKept: false, + } as unknown as CommerceAppInstallResult; + } + + try { + const result = await this.operations.commerceAppInstall(this.instance, path, { + siteId: site, + keepArchive: !cleanArchive, + waitOptions: { + timeoutSeconds: timeout || undefined, + onPoll: (info) => { + if (!this.jsonEnabled()) { + this.log( + t('commands.cap.install.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: info.status, + elapsed: Math.floor(info.elapsedSeconds).toString(), + }), + ); + } + }, + }, + }); + + const durationSec = result.execution.duration ? (result.execution.duration / 1000).toFixed(1) : 'N/A'; + this.log( + t('commands.cap.install.completed', 'Install completed: {{status}} (duration: {{duration}}s)', { + status: result.execution.exit_status?.code || result.execution.execution_status, + duration: durationSec, + }), + ); + + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: result, + }); + + return result; + } catch (error) { + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + data: error instanceof JobExecutionError ? error.execution : undefined, + }); + + if (error instanceof JobExecutionError) { + await this.showJobLog(error.execution); + this.error( + t('commands.cap.install.failed', 'Install failed: {{status}}', { + status: error.execution.exit_status?.code || 'ERROR', + }), + ); + } + if (error instanceof Error) { + this.error(t('commands.cap.install.error', 'Install error: {{message}}', {message: error.message})); + } + throw error; + } + } +} diff --git a/packages/b2c-cli/src/commands/cap/list.ts b/packages/b2c-cli/src/commands/cap/list.ts new file mode 100644 index 00000000..f4f2994a --- /dev/null +++ b/packages/b2c-cli/src/commands/cap/list.ts @@ -0,0 +1,214 @@ +/* + * 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 {JobCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + discoverLocalApps, + listInstalledApps, + type CommerceFeatureState, + type LocalCommerceApp, + type ListInstalledAppsResult, +} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {t, withDocs} from '../../i18n/index.js'; + +const LOCAL_COLUMNS: Record> = { + path: { + header: 'Path', + get: (app) => app.path, + }, + id: { + header: 'ID', + get: (app) => app.manifest.id, + }, + name: { + header: 'Name', + get: (app) => app.manifest.name, + }, + version: { + header: 'Version', + get: (app) => app.manifest.version, + }, + domain: { + header: 'Domain', + get: (app) => app.manifest.domain, + }, +}; + +const LOCAL_DEFAULT_COLUMNS = ['id', 'name', 'version', 'domain', 'path']; + +const REMOTE_COLUMNS: Record> = { + siteId: { + header: 'Site ID', + get: (s) => s.siteId, + }, + featureName: { + header: 'Name', + get: (s) => s.featureName, + }, + featureType: { + header: 'Type', + get: (s) => s.featureType, + }, + featureSource: { + header: 'Source', + get: (s) => FEATURE_SOURCE_LABELS[s.featureSource] ?? s.featureSource, + }, + installStatus: { + header: 'Install Status', + get: (s) => s.installStatus, + }, + configStatus: { + header: 'Config Status', + get: (s) => s.configStatus, + }, + version: { + header: 'Version', + get: (s) => s.featureVersionId, + }, + domain: { + header: 'Domain', + get: (s) => s.featureDomain, + }, + installedAt: { + header: 'Installed At', + get: (s) => s.installedAt, + }, +}; + +const FEATURE_SOURCE_LABELS: Record = { + WebDAV: 'CUSTOM', + AppRegistry: 'REGISTRY', +}; + +const REMOTE_DEFAULT_COLUMNS = [ + 'siteId', + 'featureName', + 'featureType', + 'featureSource', + 'installStatus', + 'configStatus', + 'version', + 'installedAt', +]; + +export default class CapList extends JobCommand { + static description = withDocs( + t('commands.cap.list.description', 'List Commerce Apps locally or installed on a B2C Commerce instance'), + '/cli/cap.html#b2c-cap-list', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --local', + '<%= config.bin %> <%= command.id %> --local --project-directory ./my-workspace', + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --site-id RefArch', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + ...JobCommand.baseFlags, + local: Flags.boolean({ + char: 'l', + description: 'List locally detected Commerce App Packages (no instance required)', + default: false, + }), + 'site-id': Flags.string({ + char: 's', + description: 'Site IDs to query (comma-separated). If omitted, queries all sites.', + multiple: true, + multipleNonGreedy: true, + delimiter: ',', + aliases: ['site'], + }), + timeout: Flags.integer({ + char: 't', + description: 'Timeout in seconds (default: no timeout)', + }), + }; + + protected operations = { + discoverLocalApps, + listInstalledApps, + }; + + async run(): Promise { + const {local, 'site-id': siteId, timeout} = this.flags; + + if (local) { + return this.runLocal(); + } + + return this.runRemote(siteId, timeout); + } + + private async runLocal(): Promise { + const searchPath = this.resolvedConfig.values.projectDirectory || process.cwd(); + + this.log( + t('commands.cap.list.discovering', 'Discovering Commerce App Packages in {{path}}...', {path: searchPath}), + ); + + const apps = await this.operations.discoverLocalApps(searchPath); + + if (apps.length === 0) { + this.log(t('commands.cap.list.noLocalApps', 'No Commerce App Packages found.')); + return apps; + } + + this.log(t('commands.cap.list.foundLocal', 'Found {{count}} Commerce App Package(s):', {count: apps.length})); + + if (!this.jsonEnabled()) { + createTable(LOCAL_COLUMNS).render(apps, LOCAL_DEFAULT_COLUMNS); + } + + return apps; + } + + private async runRemote(site: string[] | undefined, timeout: number | undefined): Promise { + this.requireOAuthCredentials(); + this.requireWebDavCredentials(); + + const hostname = this.resolvedConfig.values.hostname!; + + this.log(t('commands.cap.list.fetching', 'Fetching installed Commerce Apps from {{hostname}}...', {hostname})); + + const result = await this.operations.listInstalledApps(this.instance, { + sites: site, + waitOptions: { + timeoutSeconds: timeout || undefined, + onPoll: (info) => { + if (!this.jsonEnabled()) { + this.log( + t('commands.cap.list.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: info.status, + elapsed: Math.floor(info.elapsedSeconds).toString(), + }), + ); + } + }, + }, + }); + + if (result.features.length === 0) { + this.log(t('commands.cap.list.noInstalledApps', 'No installed Commerce Apps found.')); + return result; + } + + this.log( + t('commands.cap.list.foundRemote', 'Found {{count}} installed Commerce App(s):', { + count: result.features.length, + }), + ); + + if (!this.jsonEnabled()) { + createTable(REMOTE_COLUMNS).render(result.features, REMOTE_DEFAULT_COLUMNS); + } + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/cap/package.ts b/packages/b2c-cli/src/commands/cap/package.ts new file mode 100644 index 00000000..05344e08 --- /dev/null +++ b/packages/b2c-cli/src/commands/cap/package.ts @@ -0,0 +1,69 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {commerceAppPackage, type CommerceAppPackageResult} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {t, withDocs} from '../../i18n/index.js'; + +export default class CapPackage extends BaseCommand { + static args = { + path: Args.string({ + description: 'Path to the CAP source directory', + required: true, + }), + }; + + static description = withDocs( + t('commands.cap.package.description', 'Package a Commerce App directory into a distributable .zip file'), + '/cli/cap.html#b2c-cap-package', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5', + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5 --output ./dist', + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5 --output ./dist/my-app.zip', + ]; + + static flags = { + output: Flags.string({ + char: 'o', + description: 'Output path (directory or .zip filename). Defaults to current directory.', + }), + }; + + protected operations = { + commerceAppPackage, + }; + + async run(): Promise { + const {path} = this.args; + const {output} = this.flags; + + this.log(t('commands.cap.package.packaging', 'Packaging CAP: {{path}}', {path})); + + let result: CommerceAppPackageResult; + try { + result = await this.operations.commerceAppPackage(path, {outputPath: output}); + } catch (error) { + if (error instanceof Error) { + this.error(t('commands.cap.package.error', 'Package error: {{message}}', {message: error.message})); + } + throw error; + } + + this.log( + t('commands.cap.package.completed', 'Packaged {{name}} v{{version}} → {{outputPath}}', { + name: result.manifest.name, + version: result.manifest.version, + outputPath: result.outputPath, + }), + ); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/cap/pull.ts b/packages/b2c-cli/src/commands/cap/pull.ts new file mode 100644 index 00000000..b0e9a760 --- /dev/null +++ b/packages/b2c-cli/src/commands/cap/pull.ts @@ -0,0 +1,152 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + listInstalledApps, + pullCommerceApps, + type PullCommerceAppsResult, +} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {t, withDocs} from '../../i18n/index.js'; + +export default class CapPull extends JobCommand { + static description = withDocs( + t('commands.cap.pull.description', 'Pull installed Commerce Apps from a B2C Commerce instance'), + '/cli/cap.html#b2c-cap-pull', + ); + + static args = { + appName: Args.string({ + description: 'Commerce App feature name to pull (e.g. avalara-tax). If omitted, pulls all registry apps.', + required: false, + }), + }; + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> avalara-tax', + '<%= config.bin %> <%= command.id %> --site-id RefArch', + '<%= config.bin %> <%= command.id %> --output ./my-apps', + ]; + + static flags = { + ...JobCommand.baseFlags, + 'site-id': Flags.string({ + char: 's', + description: 'Site ID to query for installed apps. If omitted, queries all sites.', + aliases: ['site'], + }), + output: Flags.string({ + char: 'o', + description: 'Output directory (default: ./commerce-apps)', + default: 'commerce-apps', + }), + timeout: Flags.integer({ + char: 't', + description: 'Timeout in seconds (default: no timeout)', + }), + }; + + protected operations = { + listInstalledApps, + pullCommerceApps, + }; + + async run(): Promise { + this.requireOAuthCredentials(); + this.requireWebDavCredentials(); + + const {appName} = this.args; + const {'site-id': siteId, output, timeout} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log(t('commands.cap.pull.fetching', 'Fetching installed apps from {{hostname}}...', {hostname})); + + const listResult = await this.operations.listInstalledApps(this.instance, { + sites: siteId ? [siteId] : undefined, + waitOptions: { + timeoutSeconds: timeout || undefined, + onPoll: (info) => { + if (!this.jsonEnabled()) { + this.log( + t('commands.cap.pull.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: info.status, + elapsed: Math.floor(info.elapsedSeconds).toString(), + }), + ); + } + }, + }, + }); + + // Deduplicate features by name (same app installed on multiple sites shares the same version) + const uniqueFeatures = new Map(); + for (const f of listResult.features) { + if (!uniqueFeatures.has(f.featureName)) { + uniqueFeatures.set(f.featureName, f); + } + } + + let toPull: (typeof listResult.features)[0][]; + if (appName) { + const feature = uniqueFeatures.get(appName); + if (!feature) { + this.error( + t('commands.cap.pull.notFound', 'Commerce App "{{appName}}" not found on {{hostname}}', { + appName, + hostname, + }), + ); + } + toPull = [feature]; + } else { + toPull = [...uniqueFeatures.values()].filter((f) => f.featureSource === 'AppRegistry'); + } + + if (toPull.length === 0) { + this.log(t('commands.cap.pull.noApps', 'No registry-sourced apps found.')); + return {pulled: [], failed: []}; + } + + this.log( + t('commands.cap.pull.pulling', 'Pulling {{count}} app(s)...', { + count: toPull.length, + }), + ); + + const result = await this.operations.pullCommerceApps(this.instance, toPull, { + outputDir: output, + }); + + if (!this.jsonEnabled()) { + const bold = ''; + const dim = ''; + const cyan = ''; + const yellow = ''; + const red = ''; + const reset = ''; + + for (const app of result.pulled) { + const relativePath = path.relative(process.cwd(), app.extractedPath); + const sourceNote = app.source === 'github' ? ` ${yellow}(from GitHub)${reset}` : ''; + process.stdout.write(`\n ${bold}${app.featureName}${reset} v${app.version}${sourceNote}\n`); + process.stdout.write(` ${dim}domain: ${app.domain}${reset}\n`); + process.stdout.write(` ${cyan}→ ./${relativePath}${reset}\n`); + } + + for (const fail of result.failed) { + process.stdout.write(`\n ${red}${fail.featureName}: ${fail.error}${reset}\n`); + } + + process.stdout.write('\n'); + } + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/cap/tasks.ts b/packages/b2c-cli/src/commands/cap/tasks.ts new file mode 100644 index 00000000..af3493aa --- /dev/null +++ b/packages/b2c-cli/src/commands/cap/tasks.ts @@ -0,0 +1,124 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {listInstalledApps, type CommerceFeatureState} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {t, withDocs} from '../../i18n/index.js'; + +interface ConfigTask { + name: string; + description: string; + link: string; + taskNumber: string; +} + +export default class CapTasks extends JobCommand { + static args = { + appName: Args.string({ + description: 'Commerce App feature name (e.g. avalara-tax)', + required: true, + }), + }; + + static description = withDocs( + t('commands.cap.tasks.description', 'List configuration tasks for an installed Commerce App'), + '/cli/cap.html#b2c-cap-tasks', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> avalara-tax --site-id RefArch', + '<%= config.bin %> <%= command.id %> avalara-tax --site-id RefArch --json', + ]; + + static flags = { + ...JobCommand.baseFlags, + 'site-id': Flags.string({ + char: 's', + description: 'Site ID to query', + required: true, + aliases: ['site'], + }), + timeout: Flags.integer({ + char: 't', + description: 'Timeout in seconds (default: no timeout)', + }), + }; + + protected operations = { + listInstalledApps, + }; + + async run(): Promise<{feature: CommerceFeatureState; tasks: ConfigTask[]}> { + this.requireOAuthCredentials(); + this.requireWebDavCredentials(); + + const {appName: name} = this.args; + const {'site-id': siteId, timeout} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log( + t('commands.cap.tasks.fetching', 'Fetching configuration tasks for {{name}} on {{hostname}}...', { + name, + hostname, + }), + ); + + const result = await this.operations.listInstalledApps(this.instance, { + sites: [siteId], + waitOptions: { + timeoutSeconds: timeout || undefined, + onPoll: (info) => { + if (!this.jsonEnabled()) { + this.log( + t('commands.cap.tasks.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: info.status, + elapsed: Math.floor(info.elapsedSeconds).toString(), + }), + ); + } + }, + }, + }); + + const feature = result.features.find((f) => f.featureName === name); + if (!feature) { + this.error( + t('commands.cap.tasks.notFound', 'Commerce App "{{name}}" not found on site {{siteId}}', {name, siteId}), + ); + } + + const tasks = (feature.configTasks ?? []) as ConfigTask[]; + + if (tasks.length === 0) { + this.log(t('commands.cap.tasks.noTasks', 'No configuration tasks found.')); + return {feature, tasks}; + } + + this.log( + t('commands.cap.tasks.found', 'Configuration tasks for {{name}} ({{status}}):', { + name: feature.featureName, + status: feature.configStatus, + }), + ); + + if (!this.jsonEnabled()) { + const bold = ''; + const dim = ''; + const cyan = ''; + const reset = ''; + + for (const task of tasks) { + process.stdout.write(`\n ${bold}${task.taskNumber}. ${task.name}${reset}\n`); + process.stdout.write(` ${dim}${task.description}${reset}\n`); + process.stdout.write(` ${cyan}→ https://${hostname}${task.link}${reset}\n`); + } + } + + return {feature, tasks}; + } +} diff --git a/packages/b2c-cli/src/commands/cap/uninstall.ts b/packages/b2c-cli/src/commands/cap/uninstall.ts new file mode 100644 index 00000000..95f4c3a1 --- /dev/null +++ b/packages/b2c-cli/src/commands/cap/uninstall.ts @@ -0,0 +1,162 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + commerceAppUninstall, + listInstalledApps, + JobExecutionError, + type CommerceAppUninstallResult, +} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {t, withDocs} from '../../i18n/index.js'; + +export default class CapUninstall extends JobCommand { + static args = { + appName: Args.string({ + description: 'App ID to uninstall (from commerce-app.json "id" field, e.g. "avalara-tax")', + required: true, + }), + }; + + static description = withDocs( + t('commands.cap.uninstall.description', 'Uninstall a Commerce App from a B2C Commerce instance'), + '/cli/cap.html#b2c-cap-uninstall', + ); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %> avalara-tax --site-id RefArch']; + + static flags = { + ...JobCommand.baseFlags, + 'site-id': Flags.string({ + char: 's', + description: 'Site ID to uninstall the Commerce App from', + required: true, + aliases: ['site'], + }), + timeout: Flags.integer({ + char: 't', + description: 'Timeout in seconds (default: no timeout)', + }), + }; + + protected operations = { + commerceAppUninstall, + listInstalledApps, + }; + + async run(): Promise { + this.requireOAuthCredentials(); + this.requireWebDavCredentials(); + + const {appName} = this.args; + const {'site-id': site, timeout} = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + this.log( + t('commands.cap.uninstall.lookingUp', 'Looking up {{appName}} on {{hostname}} (site: {{site}})...', { + appName, + hostname, + site, + }), + ); + + const listResult = await this.operations.listInstalledApps(this.instance, { + sites: [site], + waitOptions: {timeoutSeconds: timeout || undefined}, + }); + + const feature = listResult.features.find((f) => f.featureName === appName); + if (!feature) { + this.error( + t('commands.cap.uninstall.notFound', 'Commerce App "{{appName}}" not found on site {{site}}', { + appName, + site, + }), + ); + } + + const domain = feature.featureDomain; + + this.log( + t('commands.cap.uninstall.uninstalling', 'Uninstalling {{appName}} from {{hostname}} (site: {{site}})...', { + appName, + hostname, + site, + }), + ); + + const context = this.createContext('cap:uninstall', {appName, domain, site, hostname}); + const beforeResult = await this.runBeforeHooks(context); + if (beforeResult.skip) { + this.log( + t('commands.cap.uninstall.skipped', 'Uninstall skipped: {{reason}}', { + reason: beforeResult.skipReason || 'skipped by plugin', + }), + ); + return { + execution: {execution_status: 'finished', exit_status: {code: 'skipped'}}, + appName, + } as unknown as CommerceAppUninstallResult; + } + + try { + const result = await this.operations.commerceAppUninstall(this.instance, appName, domain, { + siteId: site, + waitOptions: { + timeoutSeconds: timeout || undefined, + onPoll: (info) => { + if (!this.jsonEnabled()) { + this.log( + t('commands.cap.uninstall.progress', ' Status: {{status}} ({{elapsed}}s elapsed)', { + status: info.status, + elapsed: Math.floor(info.elapsedSeconds).toString(), + }), + ); + } + }, + }, + }); + + const durationSec = result.execution.duration ? (result.execution.duration / 1000).toFixed(1) : 'N/A'; + this.log( + t('commands.cap.uninstall.completed', 'Uninstall completed: {{status}} (duration: {{duration}}s)', { + status: result.execution.exit_status?.code || result.execution.execution_status, + duration: durationSec, + }), + ); + + await this.runAfterHooks(context, { + success: true, + duration: Date.now() - context.startTime, + data: result, + }); + + return result; + } catch (error) { + await this.runAfterHooks(context, { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - context.startTime, + data: error instanceof JobExecutionError ? error.execution : undefined, + }); + + if (error instanceof JobExecutionError) { + await this.showJobLog(error.execution); + this.error( + t('commands.cap.uninstall.failed', 'Uninstall failed: {{status}}', { + status: error.execution.exit_status?.code || 'ERROR', + }), + ); + } + if (error instanceof Error) { + this.error(t('commands.cap.uninstall.error', 'Uninstall error: {{message}}', {message: error.message})); + } + throw error; + } + } +} diff --git a/packages/b2c-cli/src/commands/cap/validate.ts b/packages/b2c-cli/src/commands/cap/validate.ts new file mode 100644 index 00000000..7a4cc428 --- /dev/null +++ b/packages/b2c-cli/src/commands/cap/validate.ts @@ -0,0 +1,61 @@ +/* + * 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 {Args} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {validateCap, type CapValidationResult} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {t, withDocs} from '../../i18n/index.js'; + +export default class CapValidate extends BaseCommand { + static args = { + path: Args.string({ + description: 'Path to a CAP directory or .zip file', + required: true, + }), + }; + + static description = withDocs( + t('commands.cap.validate.description', 'Validate the structure and manifest of a Commerce App Package (CAP)'), + '/cli/cap.html#b2c-cap-validate', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5', + '<%= config.bin %> <%= command.id %> ./commerce-avalara-tax-app-v0.2.5.zip', + ]; + + async run(): Promise { + const {path} = this.args; + + this.log(t('commands.cap.validate.validating', 'Validating CAP: {{path}}', {path})); + + const result = await validateCap(path); + + if (result.errors.length > 0) { + this.log(`\nErrors (${result.errors.length}):`); + for (const err of result.errors) { + this.log(` ✗ ${err}`); + } + } + + if (result.warnings.length > 0) { + this.log(`\nWarnings (${result.warnings.length}):`); + for (const warn of result.warnings) { + this.log(` ⚠ ${warn}`); + } + } + + if (result.valid) { + const appInfo = result.manifest ? ` (${result.manifest.name} v${result.manifest.version})` : ''; + this.log(t('commands.cap.validate.valid', '\n✓ CAP is valid{{appInfo}}', {appInfo})); + } else { + this.error(`CAP validation failed with ${result.errors.length} error(s)`, {exit: 1}); + } + + return result; + } +} diff --git a/packages/b2c-tooling-sdk/data/xsd/abtest.xsd b/packages/b2c-tooling-sdk/data/xsd/abtest.xsd index 7a522eca..88355b55 100644 --- a/packages/b2c-tooling-sdk/data/xsd/abtest.xsd +++ b/packages/b2c-tooling-sdk/data/xsd/abtest.xsd @@ -170,6 +170,7 @@ + @@ -199,6 +200,11 @@ + + + + + @@ -256,6 +262,13 @@ + + + + + + + @@ -344,4 +357,4 @@ - \ No newline at end of file + diff --git a/packages/b2c-tooling-sdk/data/xsd/bmext.xsd b/packages/b2c-tooling-sdk/data/xsd/bmext.xsd index c51ddc6f..38bda44f 100644 --- a/packages/b2c-tooling-sdk/data/xsd/bmext.xsd +++ b/packages/b2c-tooling-sdk/data/xsd/bmext.xsd @@ -57,6 +57,11 @@ Please note: This element is NOT supported for custom menu actions. + + + SCAPI paths for BM module permissions. Please note: This element is NOT supported for custom menu actions. + + Please note: This element is NOT supported for custom menu actions. @@ -168,6 +173,19 @@ + + + + + + + + + + + + + diff --git a/packages/b2c-tooling-sdk/data/xsd/commercefeaturestate.xsd b/packages/b2c-tooling-sdk/data/xsd/commercefeaturestate.xsd new file mode 100644 index 00000000..f9d852d0 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/xsd/commercefeaturestate.xsd @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/data/xsd/index.json b/packages/b2c-tooling-sdk/data/xsd/index.json index ebfad33b..d45e4b49 100644 --- a/packages/b2c-tooling-sdk/data/xsd/index.json +++ b/packages/b2c-tooling-sdk/data/xsd/index.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "generatedAt": "2026-01-10T22:14:13.492Z", + "generatedAt": "2026-04-21T18:00:00.000Z", "entries": [ { "id": "abtest", @@ -26,6 +26,10 @@ "id": "catalog", "filePath": "catalog.xsd" }, + { + "id": "commercefeaturestate", + "filePath": "commercefeaturestate.xsd" + }, { "id": "coupon", "filePath": "coupon.xsd" @@ -206,6 +210,10 @@ "id": "store", "filePath": "store.xsd" }, + { + "id": "storefronts", + "filePath": "storefronts.xsd" + }, { "id": "tax", "filePath": "tax.xsd" @@ -219,4 +227,4 @@ "filePath": "xml.xsd" } ] -} \ No newline at end of file +} diff --git a/packages/b2c-tooling-sdk/data/xsd/library.xsd b/packages/b2c-tooling-sdk/data/xsd/library.xsd index 9dee38b4..ea8ec7d9 100644 --- a/packages/b2c-tooling-sdk/data/xsd/library.xsd +++ b/packages/b2c-tooling-sdk/data/xsd/library.xsd @@ -163,6 +163,14 @@ + + + + The content-variant-assignments can only be set for default content. Each default content can have + multiple variant content assigned in a specific order. + + + @@ -254,6 +262,31 @@ + + + + + These assignments represent variant content linked to a default content. Each default content can have + multiple variant content in a specific order. + + + + + + + + + + + Represents a variant content assignment with position for ordering. + + + + + + + + diff --git a/packages/b2c-tooling-sdk/data/xsd/order.xsd b/packages/b2c-tooling-sdk/data/xsd/order.xsd index 213786a4..a26069f5 100644 --- a/packages/b2c-tooling-sdk/data/xsd/order.xsd +++ b/packages/b2c-tooling-sdk/data/xsd/order.xsd @@ -1197,6 +1197,8 @@ + + diff --git a/packages/b2c-tooling-sdk/data/xsd/pagemetatag.xsd b/packages/b2c-tooling-sdk/data/xsd/pagemetatag.xsd index a4f708fe..c1ccde9d 100644 --- a/packages/b2c-tooling-sdk/data/xsd/pagemetatag.xsd +++ b/packages/b2c-tooling-sdk/data/xsd/pagemetatag.xsd @@ -78,6 +78,7 @@ + diff --git a/packages/b2c-tooling-sdk/data/xsd/search2.xsd b/packages/b2c-tooling-sdk/data/xsd/search2.xsd index 2fc0120e..e184d0f9 100644 --- a/packages/b2c-tooling-sdk/data/xsd/search2.xsd +++ b/packages/b2c-tooling-sdk/data/xsd/search2.xsd @@ -21,6 +21,7 @@ + + @@ -252,6 +254,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -371,6 +396,20 @@ + + + + + + + + + + + + + + diff --git a/packages/b2c-tooling-sdk/data/xsd/sort.xsd b/packages/b2c-tooling-sdk/data/xsd/sort.xsd index 46a055de..6a55dbd3 100644 --- a/packages/b2c-tooling-sdk/data/xsd/sort.xsd +++ b/packages/b2c-tooling-sdk/data/xsd/sort.xsd @@ -20,6 +20,7 @@ + @@ -33,6 +34,7 @@ + @@ -266,6 +268,12 @@ + + + + + + @@ -275,6 +283,7 @@ + @@ -285,9 +294,23 @@ - + + + + + + + + + + + + + + + @@ -295,4 +318,11 @@ + + + + + + + diff --git a/packages/b2c-tooling-sdk/data/xsd/storefronts.xsd b/packages/b2c-tooling-sdk/data/xsd/storefronts.xsd new file mode 100644 index 00000000..5ca54155 --- /dev/null +++ b/packages/b2c-tooling-sdk/data/xsd/storefronts.xsd @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/b2c-tooling-sdk/eslint.config.mjs b/packages/b2c-tooling-sdk/eslint.config.mjs index e86e323b..7634b391 100644 --- a/packages/b2c-tooling-sdk/eslint.config.mjs +++ b/packages/b2c-tooling-sdk/eslint.config.mjs @@ -17,7 +17,7 @@ headerPlugin.rules.header.meta.schema = false; export default [ includeIgnoreFile(gitignorePath), { - ignores: ['**/*.generated.ts'], + ignores: ['**/*.generated.ts', 'test/fixtures/**'], }, ...tseslint.configs.recommended, prettierPlugin, diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 8d3933fd..d1f8a4c5 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -79,6 +79,17 @@ "default": "./dist/cjs/operations/jobs/index.js" } }, + "./operations/cap": { + "development": "./src/operations/cap/index.ts", + "import": { + "types": "./dist/esm/operations/cap/index.d.ts", + "default": "./dist/esm/operations/cap/index.js" + }, + "require": { + "types": "./dist/cjs/operations/cap/index.d.ts", + "default": "./dist/cjs/operations/cap/index.js" + } + }, "./operations/mrt": { "development": "./src/operations/mrt/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/cli/lifecycle.ts b/packages/b2c-tooling-sdk/src/cli/lifecycle.ts index f16418b9..14511379 100644 --- a/packages/b2c-tooling-sdk/src/cli/lifecycle.ts +++ b/packages/b2c-tooling-sdk/src/cli/lifecycle.ts @@ -53,7 +53,9 @@ export type B2COperationType = | 'code:download' | 'code:activate' | 'site-archive:import' - | 'site-archive:export'; + | 'site-archive:export' + | 'cap:install' + | 'cap:uninstall'; /** * Context provided to lifecycle hooks for a B2C operation. diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 323d5140..e651cfc9 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -318,6 +318,37 @@ export { revokeRole, } from './operations/users/index.js'; +// Operations - CAP (Commerce App Packages) +export { + validateCap, + commerceAppInstall, + commerceAppUninstall, + commerceAppPackage, + discoverLocalApps, + listInstalledApps, + parseCommerceFeatureStatesXml, + readManifest, + pullCommerceApps, +} from './operations/cap/index.js'; +export type { + CapValidationResult, + CommerceAppManifest, + CommerceAppInstallOptions, + CommerceAppInstallResult, + CommerceAppUninstallOptions, + CommerceAppUninstallResult, + CommerceAppPackageOptions, + CommerceAppPackageResult, + CommerceFeatureState, + LocalCommerceApp, + ListInstalledAppsOptions, + ListInstalledAppsResult, + PullCommerceAppsOptions, + PullCommerceAppsResult, + PulledApp, + PullSource, +} from './operations/cap/index.js'; + // Operations - Roles export {getRole, listRoles} from './operations/roles/index.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/cap/fixtures/commerce-feature-states-stub.xml b/packages/b2c-tooling-sdk/src/operations/cap/fixtures/commerce-feature-states-stub.xml new file mode 100644 index 00000000..7c15daee --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/fixtures/commerce-feature-states-stub.xml @@ -0,0 +1,14 @@ + + + + ISV_APP + AppRegistry + tax + INSTALLED + NOT_CONFIGURED + 0.2.7 + 2026-03-24T16:35:02.000Z + [{"name":"Verify Avalara Service Profile","description":"Confirm the Avalara service profile is configured correctly in Operations > Services > Profiles.","link":"/on/demandware.store/Sites-Site/default/ServiceProfile-DisplayAll","taskNumber":"1"},{"name":"Verify Avalara Service Credentials","description":"Confirm the Avalara service credentials are configured correctly in Operations > Services > Credentials.","link":"/on/demandware.store/Sites-Site/default/ServiceCredential-DisplayAll","taskNumber":"2"},{"name":"Verify Avalara Service Definition","description":"Confirm the Avalara service definition is configured correctly in Operations > Services.","link":"/on/demandware.store/Sites-Site/default/Service-DisplayAll","taskNumber":"3"},{"name":"Verify Custom Site Preferences","description":"Confirm Avalara custom site preferences (e.g. AvaTax preference group) in Merchant Tools > Site Preferences.","link":"/on/demandware.store/Sites-Site/default/ViewApplication-BM?SelectedMenuItem=site-prefs_custom_prefs#/?preference#site_preference_group_attributes!id!AvaTax","taskNumber":"4"}] + {"jobId":"sfcc-install-commerce-app","jobExecutionId":"210","logFilePath":"/Sites/LOGS/jobs/sfcc-install-commerce-app/Job-sfcc-install-commerce-app-4db8124510b43bcedeaad79a0f.log","foldersAdded":{"site_cartridges":["int_avatax"],"bm_cartridges":["bm_avatax"]},"impexUninstallData":"[{\"path\":\"services.xml\",\"content\":\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\"?>\\n<services xmlns=\\\"http://www.demandware.com/xml/impex/services/2014-09-26\\\">\\n <service-credential service-credential-id=\\\"credentials.avatax.rest\\\" mode=\\\"delete\\\"/>\\n <service-profile service-profile-id=\\\"profile.avatax.rest\\\" mode=\\\"delete\\\"/>\\n <service service-id=\\\"avatax.rest.all\\\" mode=\\\"delete\\\">\\n <service-type>GENERIC</service-type>\\n </service>\\n</services>\\n\"}]"} + + diff --git a/packages/b2c-tooling-sdk/src/operations/cap/index.ts b/packages/b2c-tooling-sdk/src/operations/cap/index.ts new file mode 100644 index 00000000..2b49f89f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/index.ts @@ -0,0 +1,74 @@ +/* + * 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 + */ +/** + * Commerce App Package (CAP) operations for B2C Commerce. + * + * This module provides functions for validating, packaging, installing, and + * uninstalling Commerce App Packages (CAPs) on B2C Commerce instances. + * + * ## CAP Operations + * + * - {@link validateCap} - Validate CAP structure and manifest (local, no instance required) + * - {@link commerceAppInstall} - Install a CAP via the sfcc-install-commerce-app job + * - {@link commerceAppUninstall} - Uninstall a CAP via the sfcc-uninstall-commerce-app job + * - {@link commerceAppPackage} - Package a CAP directory into a distributable .zip + * - {@link discoverLocalApps} - Discover local CAPs by finding commerce-app.json files + * - {@link listInstalledApps} - List installed apps on an instance via commerce_feature_states export + * + * ## Usage + * + * ```typescript + * import { + * validateCap, + * commerceAppInstall, + * commerceAppUninstall, + * commerceAppPackage, + * discoverLocalApps, + * listInstalledApps, + * } from '@salesforce/b2c-tooling-sdk/operations/cap'; + * + * // Validate locally + * const result = await validateCap('./my-commerce-app'); + * if (!result.valid) console.error(result.errors); + * + * // Package for distribution + * const pkg = await commerceAppPackage('./my-commerce-app'); + * + * // Install on an instance + * await commerceAppInstall(instance, './my-commerce-app', { siteId: 'RefArch' }); + * + * // Uninstall + * await commerceAppUninstall(instance, 'my-app', 'tax', { siteId: 'RefArch' }); + * ``` + * + * @module operations/cap + */ + +export {validateCap} from './validate.js'; +export type {CapValidationResult, CommerceAppManifest} from './validate.js'; + +// Re-export JobExecutionError for convenience in CLI commands +export {JobExecutionError} from '../jobs/run.js'; + +export {commerceAppInstall, readManifest} from './install.js'; +export type {CommerceAppInstallOptions, CommerceAppInstallResult} from './install.js'; + +export {commerceAppUninstall} from './uninstall.js'; +export type {CommerceAppUninstallOptions, CommerceAppUninstallResult} from './uninstall.js'; + +export {commerceAppPackage} from './package.js'; +export type {CommerceAppPackageOptions, CommerceAppPackageResult} from './package.js'; + +export {discoverLocalApps, listInstalledApps, parseCommerceFeatureStatesXml} from './list.js'; + +export {pullCommerceApps} from './pull.js'; +export type {PullCommerceAppsOptions, PullCommerceAppsResult, PulledApp, PullSource} from './pull.js'; +export type { + CommerceFeatureState, + LocalCommerceApp, + ListInstalledAppsOptions, + ListInstalledAppsResult, +} from './list.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/cap/install.ts b/packages/b2c-tooling-sdk/src/operations/cap/install.ts new file mode 100644 index 00000000..2eaed6a5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/install.ts @@ -0,0 +1,230 @@ +/* + * 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 + */ +/** + * Commerce App Package (CAP) installation. + * + * Uploads a CAP to WebDAV and runs the sfcc-install-commerce-app system job. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import JSZip from 'jszip'; +import {B2CInstance} from '../../instance/index.js'; +import {getLogger} from '../../logging/logger.js'; +import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; +import {addDirectoryToZip} from '../util/zip.js'; +import {type CommerceAppManifest} from './validate.js'; + +const INSTALL_JOB_ID = 'sfcc-install-commerce-app'; + +/** + * Options for CAP installation. + */ +export interface CommerceAppInstallOptions { + /** Target site ID to install the app on. */ + siteId: string; + /** Keep the uploaded zip on the instance after install (default: false). */ + keepArchive?: boolean; + /** Wait options for job completion. */ + waitOptions?: WaitForJobOptions; +} + +/** + * Result of a CAP installation. + */ +export interface CommerceAppInstallResult { + /** Job execution details. */ + execution: JobExecution; + /** App name (id from commerce-app.json). */ + appName: string; + /** App version. */ + appVersion: string; + /** Uploaded archive filename. */ + archiveFilename: string; + /** Whether the archive was kept on the instance. */ + archiveKept: boolean; +} + +/** + * Installs a Commerce App Package (CAP) on a B2C Commerce instance. + * + * Accepts a local directory or zip file. Reads the commerce-app.json manifest + * to determine app name, version, and domain. Uploads the zip to WebDAV and + * executes the sfcc-install-commerce-app system job. + * + * @param instance - B2C instance to install to + * @param target - Path to a CAP directory or .zip file + * @param options - Install options including required siteId + * @returns Install result with job execution details + * @throws JobExecutionError if the install job fails + * + * @example + * ```typescript + * const result = await commerceAppInstall(instance, './commerce-avalara-tax-app-v0.2.5', { + * siteId: 'RefArch', + * }); + * ``` + */ +export async function commerceAppInstall( + instance: B2CInstance, + target: string, + options: CommerceAppInstallOptions, +): Promise { + const logger = getLogger(); + const {siteId: rawSiteId, keepArchive = false, waitOptions} = options; + const siteId = normalizeSiteId(rawSiteId); + + if (!fs.existsSync(target)) { + throw new Error(`Target not found: ${target}`); + } + + const stat = fs.statSync(target); + let archiveContent: Buffer; + let archiveFilename: string; + let manifest: CommerceAppManifest; + + if (stat.isDirectory()) { + manifest = readManifest(target); + archiveFilename = `${manifest.id}-v${manifest.version}.zip`; + logger.debug({path: target}, `Packaging CAP directory: ${target}`); + archiveContent = await createArchiveFromDirectory(target, `${manifest.id}-v${manifest.version}`); + } else if (stat.isFile() && target.endsWith('.zip')) { + manifest = await readManifestFromZip(target); + archiveFilename = path.basename(target); + archiveContent = await fs.promises.readFile(target); + } else { + throw new Error(`Target must be a directory or .zip file: ${target}`); + } + + const uploadDir = 'Impex/commerce-apps'; + const webdavUploadPath = `${uploadDir}/${archiveFilename}`; + const appPath = `webdav/Sites/${webdavUploadPath}`; + + logger.debug({path: webdavUploadPath}, `Uploading CAP to ${webdavUploadPath}`); + await instance.webdav.mkcol(uploadDir); + await instance.webdav.put(webdavUploadPath, archiveContent, 'application/zip'); + logger.debug({path: webdavUploadPath}, `CAP uploaded: ${webdavUploadPath}`); + + // Execute the install job + logger.debug({jobId: INSTALL_JOB_ID, appName: manifest.id, siteId}, `Executing ${INSTALL_JOB_ID} job`); + + let execution: JobExecution; + + // Try direct body format first (standard OCAPI format) + const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { + params: {path: {job_id: INSTALL_JOB_ID}}, + body: { + app_name: manifest.id, + app_source: 'WebDAV', + app_domain: manifest.domain, + site_id: siteId, + app_path: appPath, + } as unknown as string, + }); + + if ( + error?.fault?.type === 'UnknownPropertyException' && + (error.fault.arguments as Record)?.document === 'job_execution_request' + ) { + // Retry with parameters format (internal/support users) + logger.warn('Retrying with parameters format for internal users'); + + const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { + params: {path: {job_id: INSTALL_JOB_ID}}, + body: { + parameters: [ + {name: 'AppName', value: manifest.id}, + {name: 'AppSource', value: 'WebDAV'}, + {name: 'AppDomain', value: manifest.domain}, + {name: 'SiteId', value: siteId}, + {name: 'AppPath', value: appPath}, + ], + } as unknown as string, + }); + + if (retryError || !retryData) { + throw new Error(retryError?.fault?.message ?? 'Failed to start install job'); + } + + execution = retryData; + } else if (error || !data) { + throw new Error(error?.fault?.message ?? 'Failed to start install job'); + } else { + execution = data; + } + logger.debug({jobId: INSTALL_JOB_ID, executionId: execution.id}, `Install job started: ${execution.id}`); + + // Wait for job completion + let finalExecution: JobExecution; + try { + finalExecution = await waitForJob(instance, INSTALL_JOB_ID, execution.id!, waitOptions); + } catch (err) { + if (err instanceof JobExecutionError) { + try { + const log = await getJobLog(instance, err.execution); + logger.error({jobId: INSTALL_JOB_ID, log}, `Job log:\n${log}`); + } catch { + logger.error({jobId: INSTALL_JOB_ID}, 'Could not retrieve job log'); + } + } + throw err; + } + + // Clean up archive unless keeping + if (!keepArchive) { + await instance.webdav.delete(webdavUploadPath); + logger.debug({path: webdavUploadPath}, `Archive deleted: ${webdavUploadPath}`); + } + + return { + execution: finalExecution, + appName: manifest.id, + appVersion: manifest.version, + archiveFilename, + archiveKept: keepArchive, + }; +} + +export function readManifest(capDir: string): CommerceAppManifest { + const manifestPath = path.join(capDir, 'commerce-app.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error(`commerce-app.json not found in: ${capDir}`); + } + try { + return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as CommerceAppManifest; + } catch { + throw new Error(`Failed to parse commerce-app.json in: ${capDir}`); + } +} + +async function readManifestFromZip(zipPath: string): Promise { + const data = await fs.promises.readFile(zipPath); + const zip = await JSZip.loadAsync(data); + + // Find commerce-app.json at root or one level deep + for (const [filePath, entry] of Object.entries(zip.files)) { + if (!entry.dir && (filePath === 'commerce-app.json' || filePath.match(/^[^/]+\/commerce-app\.json$/))) { + try { + const content = await entry.async('string'); + return JSON.parse(content) as CommerceAppManifest; + } catch { + throw new Error('Failed to parse commerce-app.json from zip'); + } + } + } + throw new Error('commerce-app.json not found in zip'); +} + +/** Prefix site ID with "Sites-" if not already present. */ +export function normalizeSiteId(siteId: string): string { + return siteId.startsWith('Sites-') ? siteId : `Sites-${siteId}`; +} + +async function createArchiveFromDirectory(dirPath: string, archiveDirName: string): Promise { + const zip = new JSZip(); + const rootFolder = zip.folder(archiveDirName)!; + await addDirectoryToZip(rootFolder, dirPath); + return zip.generateAsync({type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: {level: 9}}); +} diff --git a/packages/b2c-tooling-sdk/src/operations/cap/list.ts b/packages/b2c-tooling-sdk/src/operations/cap/list.ts new file mode 100644 index 00000000..a3c9c5db --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/list.ts @@ -0,0 +1,300 @@ +/* + * 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 + */ +/** + * Commerce App listing operations. + * + * Provides functions for discovering local Commerce App Packages and listing + * installed apps on a B2C Commerce instance via site archive export. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import JSZip from 'jszip'; +import * as xml2js from 'xml2js'; +import {B2CInstance} from '../../instance/index.js'; +import {getLogger} from '../../logging/logger.js'; +import {siteArchiveExportToBuffer} from '../jobs/site-archive.js'; +import type {JobExecution, WaitForJobOptions} from '../jobs/run.js'; +import {readManifest} from './install.js'; +import type {CommerceAppManifest} from './validate.js'; + +/** + * A commerce feature state parsed from the commerce-feature-states.xml export. + */ +export interface CommerceFeatureState { + siteId: string; + featureName: string; + featureType: string; + featureSource: string; + featureDomain: string; + installStatus: string; + configStatus: string; + featureVersionId: string; + installedAt: string; + configTasks?: unknown[]; + installationMetadata?: unknown; +} + +/** + * A locally discovered Commerce App Package. + */ +export interface LocalCommerceApp { + /** Absolute path to the directory containing commerce-app.json. */ + path: string; + /** Parsed manifest from commerce-app.json. */ + manifest: CommerceAppManifest; +} + +/** + * Options for listing installed apps on an instance. + */ +export interface ListInstalledAppsOptions { + /** Specific site IDs to query. If omitted, discovers all sites via OCAPI. */ + sites?: string[]; + /** Wait options for the export job. */ + waitOptions?: WaitForJobOptions; +} + +/** + * Result of listing installed apps on an instance. + */ +export interface ListInstalledAppsResult { + /** Parsed commerce feature states from all queried sites. */ + features: CommerceFeatureState[]; + /** Job execution details. */ + execution: JobExecution; +} + +/** + * Discovers local Commerce App Packages by searching for commerce-app.json files. + * + * Walks the directory tree starting from `searchPath`, finds directories + * containing a `commerce-app.json` file, and reads each manifest. + * + * @param searchPath - Root directory to search + * @returns Array of discovered local apps with their paths and manifests + * + * @example + * ```typescript + * const apps = await discoverLocalApps('./my-workspace'); + * for (const app of apps) { + * console.log(`${app.manifest.id}@${app.manifest.version} at ${app.path}`); + * } + * ``` + */ +export async function discoverLocalApps(searchPath: string): Promise { + const logger = getLogger(); + const apps: LocalCommerceApp[] = []; + const resolvedPath = path.resolve(searchPath); + + if (!fs.existsSync(resolvedPath)) { + return apps; + } + + logger.debug({searchPath: resolvedPath}, `Discovering local CAPs in: ${resolvedPath}`); + + findCommerceApps(resolvedPath, apps, logger); + + logger.debug({count: apps.length}, `Found ${apps.length} local CAP(s)`); + return apps; +} + +/** + * Recursively finds directories containing commerce-app.json. + * Stops descending into a directory once a commerce-app.json is found there. + */ +function findCommerceApps(dir: string, apps: LocalCommerceApp[], logger: ReturnType): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, {withFileTypes: true}); + } catch { + return; + } + + const manifestPath = path.join(dir, 'commerce-app.json'); + if (fs.existsSync(manifestPath)) { + try { + const manifest = readManifest(dir); + apps.push({path: dir, manifest}); + } catch (err) { + logger.warn({path: manifestPath, error: err}, `Skipping invalid commerce-app.json: ${manifestPath}`); + } + // Don't recurse into CAP directories + return; + } + + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { + findCommerceApps(path.join(dir, entry.name), apps, logger); + } + } +} + +/** + * Lists installed Commerce Apps on a B2C instance by exporting commerce feature states. + * + * Attempts to export the `commerce_feature_states` data unit for each site. + * If the export fails (e.g. because the data unit is not yet supported on the server), + * falls back to a bundled stub fixture. + * + * @param instance - B2C instance to query + * @param options - Options including optional site filter and wait options + * @returns List of commerce feature states across all queried sites + * + * @example + * ```typescript + * const result = await listInstalledApps(instance); + * for (const state of result.features) { + * console.log(`${state.featureName} (${state.installStatus}) on ${state.siteId}`); + * } + * ``` + */ +export async function listInstalledApps( + instance: B2CInstance, + options: ListInstalledAppsOptions = {}, +): Promise { + const logger = getLogger(); + const {waitOptions} = options; + + // Determine which sites to query + let siteIds: string[]; + if (options.sites && options.sites.length > 0) { + siteIds = options.sites; + } else { + logger.debug('No sites specified, discovering all sites via OCAPI'); + const {data, error} = await instance.ocapi.GET('/sites', { + params: {query: {select: '(**)'}}, + }); + if (error || !data) { + throw new Error(error?.fault?.message ?? 'Failed to list sites'); + } + siteIds = (data.data ?? []).map((s) => s.id).filter((id): id is string => !!id); + logger.debug({siteIds}, `Discovered ${siteIds.length} site(s)`); + } + + if (siteIds.length === 0) { + return {features: [], execution: undefined as unknown as JobExecution}; + } + + // Build export configuration for all sites with commerce_feature_states + const sitesConfig: Record = {}; + for (const siteId of siteIds) { + sitesConfig[siteId] = {commerce_feature_states: true}; + } + + logger.debug({siteIds}, 'Exporting commerce_feature_states'); + const exportResult = await siteArchiveExportToBuffer(instance, {sites: sitesConfig}, {waitOptions}); + + const states = await parseExportArchive(exportResult.data); + return {features: states, execution: exportResult.execution}; +} + +/** + * Parses a site archive export zip for commerce-feature-states.xml files. + */ +async function parseExportArchive(data: Buffer): Promise { + const logger = getLogger(); + const zip = await JSZip.loadAsync(data); + const states: CommerceFeatureState[] = []; + const filePaths = Object.keys(zip.files).filter((p) => !zip.files[p].dir); + + logger.debug({filePaths}, `Export archive contains ${filePaths.length} file(s)`); + + for (const [filePath, entry] of Object.entries(zip.files)) { + if (entry.dir) continue; + + const match = filePath.match(/sites\/([^/]+)\/commerce-feature-states\.xml$/); + if (match) { + const siteId = match[1]; + const xml = await entry.async('string'); + const parsed = await parseCommerceFeatureStatesXml(xml, siteId); + states.push(...parsed); + } + } + + return states; +} + +/** + * Parses a commerce-feature-states.xml string into CommerceFeatureState objects. + * + * @param xml - XML string to parse + * @param siteId - Site ID to associate with parsed states (used as fallback if not in XML attributes) + * @returns Array of parsed commerce feature states + */ +export async function parseCommerceFeatureStatesXml(xml: string, siteId: string): Promise { + const parsed = await xml2js.parseStringPromise(xml, { + explicitArray: false, + tagNameProcessors: [(name: string) => name.replace(/^[^:]+:/, '')], + }); + + if (!parsed) return []; + + const root = parsed['commerce-feature-states']; + if (!root) return []; + + let entries = root['commerce-feature-state']; + if (!entries) return []; + + // Normalize to array (xml2js uses single object when there's only one element) + if (!Array.isArray(entries)) { + entries = [entries]; + } + + return (entries as Array>).map((entry) => { + const attrs = (entry['$'] ?? {}) as Record; + + // Parse config-tasks as JSON if present + let configTasks: unknown[] | undefined; + if (typeof entry['config-tasks'] === 'string') { + try { + configTasks = JSON.parse(entry['config-tasks'] as string) as unknown[]; + } catch { + // Leave as undefined if not valid JSON + } + } + + // Parse installation-metadata as JSON if present + let installationMetadata: unknown; + if (typeof entry['installation-metadata'] === 'string') { + try { + const parsed = JSON.parse(entry['installation-metadata'] as string) as Record; + if (typeof parsed.impexUninstallData === 'string') { + try { + parsed.impexUninstallData = JSON.parse(parsed.impexUninstallData as string); + } catch { + // leave as string + } + } + installationMetadata = parsed; + } catch { + // Leave as undefined if not valid JSON + } + } + + return { + siteId: attrs['site-id'] || siteId, + featureName: attrs['feature-name'] || '', + featureType: getTextValue(entry['feature-type']), + featureSource: getTextValue(entry['feature-source']), + featureDomain: getTextValue(entry['feature-domain']), + installStatus: getTextValue(entry['install-status']), + configStatus: getTextValue(entry['config-status']), + featureVersionId: getTextValue(entry['feature-version-id']), + installedAt: getTextValue(entry['installed-at']), + configTasks, + installationMetadata, + }; + }); +} + +/** Extract text value from xml2js parsed element (may be string or object with _). */ +function getTextValue(value: unknown): string { + if (typeof value === 'string') return value; + if (value && typeof value === 'object' && '_' in (value as Record)) { + return String((value as Record)['_']); + } + return ''; +} diff --git a/packages/b2c-tooling-sdk/src/operations/cap/package.ts b/packages/b2c-tooling-sdk/src/operations/cap/package.ts new file mode 100644 index 00000000..ff268277 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/package.ts @@ -0,0 +1,113 @@ +/* + * 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 + */ +/** + * Commerce App Package (CAP) packaging. + * + * Zips a CAP directory into a distributable .zip file with the correct + * root directory naming convention ({id}-v{version}/). + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import JSZip from 'jszip'; +import {getLogger} from '../../logging/logger.js'; +import {addDirectoryToZip} from '../util/zip.js'; +import {readManifest} from './install.js'; +import {type CommerceAppManifest} from './validate.js'; + +/** + * Options for CAP packaging. + */ +export interface CommerceAppPackageOptions { + /** + * Output path for the zip file. + * - If a directory: zip is written to `{outputPath}/{id}-v{version}.zip` + * - If a .zip path: written to that exact location + * - Default: current working directory + */ + outputPath?: string; +} + +/** + * Result of CAP packaging. + */ +export interface CommerceAppPackageResult { + /** Absolute path to the produced zip file. */ + outputPath: string; + /** Parsed manifest. */ + manifest: CommerceAppManifest; +} + +/** + * Packages a CAP directory into a distributable .zip file. + * + * The zip root directory is named `{id}-v{version}/` as required by the CAP spec. + * Reads commerce-app.json to determine the app name and version. + * + * @param sourceDir - Path to the CAP directory + * @param options - Packaging options + * @returns Result with the output zip path and manifest + * + * @example + * ```typescript + * const result = await commerceAppPackage('./commerce-avalara-tax-app-v0.2.5'); + * console.log(`Packaged to: ${result.outputPath}`); + * ``` + */ +export async function commerceAppPackage( + sourceDir: string, + options: CommerceAppPackageOptions = {}, +): Promise { + const logger = getLogger(); + + if (!fs.existsSync(sourceDir)) { + throw new Error(`Source directory not found: ${sourceDir}`); + } + if (!fs.statSync(sourceDir).isDirectory()) { + throw new Error(`Source must be a directory: ${sourceDir}`); + } + + const manifest = readManifest(sourceDir); + + if (!manifest.id || !manifest.version) { + throw new Error('commerce-app.json must have "id" and "version" fields'); + } + + const archiveDirName = `${manifest.id}-v${manifest.version}`; + const zipFilename = `${archiveDirName}.zip`; + + // Determine output path + let outputZipPath: string; + if (!options.outputPath) { + outputZipPath = path.resolve(process.cwd(), zipFilename); + } else { + const resolved = path.resolve(options.outputPath); + if (resolved.endsWith('.zip')) { + outputZipPath = resolved; + } else { + outputZipPath = path.join(resolved, zipFilename); + } + } + + // Ensure output directory exists + await fs.promises.mkdir(path.dirname(outputZipPath), {recursive: true}); + + logger.debug({sourceDir, outputPath: outputZipPath}, `Packaging CAP: ${archiveDirName}`); + + const zip = new JSZip(); + const rootFolder = zip.folder(archiveDirName)!; + await addDirectoryToZip(rootFolder, sourceDir); + + const buffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: {level: 9}, + }); + + await fs.promises.writeFile(outputZipPath, buffer); + logger.debug({outputPath: outputZipPath}, `CAP packaged to: ${outputZipPath}`); + + return {outputPath: outputZipPath, manifest}; +} diff --git a/packages/b2c-tooling-sdk/src/operations/cap/pull.ts b/packages/b2c-tooling-sdk/src/operations/cap/pull.ts new file mode 100644 index 00000000..6b4f0503 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/pull.ts @@ -0,0 +1,117 @@ +/* + * 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 * as fs from 'node:fs'; +import * as path from 'node:path'; +import JSZip from 'jszip'; +import {B2CInstance} from '../../instance/index.js'; +import {getLogger} from '../../logging/logger.js'; +import type {CommerceFeatureState} from './list.js'; + +const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/SalesforceCommerceCloud/commerce-apps/main'; + +export interface PullCommerceAppsOptions { + outputDir?: string; +} + +export type PullSource = 'instance' | 'github'; + +export interface PulledApp { + featureName: string; + version: string; + domain: string; + source: PullSource; + extractedPath: string; +} + +export interface PullCommerceAppsResult { + pulled: PulledApp[]; + failed: Array<{featureName: string; error: string}>; +} + +export async function pullCommerceApps( + instance: B2CInstance, + features: CommerceFeatureState[], + options: PullCommerceAppsOptions = {}, +): Promise { + const logger = getLogger(); + const outputDir = path.resolve(options.outputDir ?? 'commerce-apps'); + + await fs.promises.mkdir(outputDir, {recursive: true}); + + const pulled: PulledApp[] = []; + const failed: Array<{featureName: string; error: string}> = []; + + for (const feature of features) { + const {featureName, featureVersionId, featureDomain} = feature; + + if (!featureVersionId) { + failed.push({featureName, error: 'No version available'}); + continue; + } + + const zipFilename = `${featureName}-v${featureVersionId}.zip`; + const webdavPath = `Impex/commerce-apps/${zipFilename}`; + + let zipData: Buffer | null = null; + let source: PullSource = 'instance'; + + // Try instance first + if (await instance.webdav.exists(webdavPath)) { + logger.debug({path: webdavPath}, `Downloading ${zipFilename} from instance`); + zipData = Buffer.from(await instance.webdav.get(webdavPath)); + } + + // Fall back to GitHub + if (!zipData) { + const githubUrl = `${GITHUB_RAW_BASE}/${featureDomain}/${featureName}/${zipFilename}`; + logger.warn({url: githubUrl}, `${zipFilename} not found on instance, trying GitHub`); + source = 'github'; + + try { + const response = await fetch(githubUrl); + if (!response.ok) { + failed.push({featureName, error: `Not found on instance or GitHub (${githubUrl})`}); + continue; + } + zipData = Buffer.from(await response.arrayBuffer()); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + failed.push({featureName, error: `GitHub download failed: ${msg}`}); + continue; + } + } + + // Extract zip + const zip = await JSZip.loadAsync(zipData); + const extractDir = path.join(outputDir, featureName); + await fs.promises.mkdir(extractDir, {recursive: true}); + + for (const [filePath, entry] of Object.entries(zip.files)) { + if (entry.dir) continue; + + // Strip the top-level archive directory (e.g. "avalara-tax-v1.1.0/...") + const parts = filePath.split('/'); + const relativePath = parts.length > 1 ? parts.slice(1).join('/') : filePath; + const fullPath = path.join(extractDir, relativePath); + + await fs.promises.mkdir(path.dirname(fullPath), {recursive: true}); + const content = await entry.async('nodebuffer'); + await fs.promises.writeFile(fullPath, content); + } + + logger.debug({featureName, extractDir}, `Extracted ${featureName} to ${extractDir}`); + + pulled.push({ + featureName, + version: featureVersionId, + domain: featureDomain, + source, + extractedPath: extractDir, + }); + } + + return {pulled, failed}; +} diff --git a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts new file mode 100644 index 00000000..6e644adf --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts @@ -0,0 +1,131 @@ +/* + * 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 + */ +/** + * Commerce App Package (CAP) uninstallation. + * + * Runs the sfcc-uninstall-commerce-app system job to remove an installed CAP. + */ +import {B2CInstance} from '../../instance/index.js'; +import {getLogger} from '../../logging/logger.js'; +import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; +import {normalizeSiteId} from './install.js'; + +const UNINSTALL_JOB_ID = 'sfcc-uninstall-commerce-app'; + +/** + * Options for CAP uninstallation. + */ +export interface CommerceAppUninstallOptions { + /** Target site ID to uninstall the app from. */ + siteId: string; + /** Wait options for job completion. */ + waitOptions?: WaitForJobOptions; +} + +/** + * Result of a CAP uninstallation. + */ +export interface CommerceAppUninstallResult { + /** Job execution details. */ + execution: JobExecution; + /** App name that was uninstalled. */ + appName: string; +} + +/** + * Uninstalls a Commerce App from a B2C Commerce instance. + * + * Executes the sfcc-uninstall-commerce-app system job which removes cartridges, + * IMPEX data, and configuration associated with the app from the target site. + * + * @param instance - B2C instance to uninstall from + * @param appName - App ID (from commerce-app.json "id" field, e.g. "avalara-tax") + * @param appDomain - App domain (e.g. "tax", "shipping") + * @param options - Uninstall options including required siteId + * @returns Uninstall result with job execution details + * @throws JobExecutionError if the uninstall job fails + * + * @example + * ```typescript + * const result = await commerceAppUninstall(instance, 'avalara-tax', 'tax', { + * siteId: 'RefArch', + * }); + * ``` + */ +export async function commerceAppUninstall( + instance: B2CInstance, + appName: string, + appDomain: string, + options: CommerceAppUninstallOptions, +): Promise { + const logger = getLogger(); + const {siteId: rawSiteId, waitOptions} = options; + const siteId = normalizeSiteId(rawSiteId); + + logger.debug({jobId: UNINSTALL_JOB_ID, appName, siteId}, `Executing ${UNINSTALL_JOB_ID} job`); + + let execution: JobExecution; + + // Try direct body format first (standard OCAPI format) + const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { + params: {path: {job_id: UNINSTALL_JOB_ID}}, + body: { + app_name: appName, + app_domain: appDomain, + site_id: siteId, + } as unknown as string, + }); + + if ( + error?.fault?.type === 'UnknownPropertyException' && + (error.fault.arguments as Record)?.document === 'job_execution_request' + ) { + // Retry with parameters format (internal/support users) + logger.warn('Retrying with parameters format for internal users'); + + const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { + params: {path: {job_id: UNINSTALL_JOB_ID}}, + body: { + parameters: [ + {name: 'AppName', value: appName}, + {name: 'AppDomain', value: appDomain}, + {name: 'SiteId', value: siteId}, + ], + } as unknown as string, + }); + + if (retryError || !retryData) { + throw new Error(retryError?.fault?.message ?? 'Failed to start uninstall job'); + } + + execution = retryData; + } else if (error || !data) { + throw new Error(error?.fault?.message ?? 'Failed to start uninstall job'); + } else { + execution = data; + } + logger.debug({jobId: UNINSTALL_JOB_ID, executionId: execution.id}, `Uninstall job started: ${execution.id}`); + + let finalExecution: JobExecution; + try { + finalExecution = await waitForJob(instance, UNINSTALL_JOB_ID, execution.id!, waitOptions); + } catch (err) { + if (err instanceof JobExecutionError) { + try { + const log = await getJobLog(instance, err.execution); + logger.error({jobId: UNINSTALL_JOB_ID, log}, `Job log:\n${log}`); + } catch { + logger.error({jobId: UNINSTALL_JOB_ID}, 'Could not retrieve job log'); + } + } + throw err; + } + + return { + execution: finalExecution, + appName, + }; +} diff --git a/packages/b2c-tooling-sdk/src/operations/cap/validate.ts b/packages/b2c-tooling-sdk/src/operations/cap/validate.ts new file mode 100644 index 00000000..4590b0d8 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/cap/validate.ts @@ -0,0 +1,266 @@ +/* + * 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 + */ +/** + * Validation logic for Commerce App Packages (CAPs). + * + * Performs structural and schema validation of a CAP directory or zip file + * without requiring a live B2C instance. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import JSZip from 'jszip'; + +/** + * Manifest from commerce-app.json. + */ +export interface CommerceAppManifest { + id: string; + name: string; + version: string; + domain: string; + description?: string; + publisher?: {name: string; url?: string; support?: string}; + dependencies?: Record; +} + +/** + * Result of CAP validation. + */ +export interface CapValidationResult { + /** Whether the CAP is valid (no errors). */ + valid: boolean; + /** Blocking errors — a CAP with errors cannot be installed. */ + errors: string[]; + /** Advisory warnings — a CAP with warnings can still be installed. */ + warnings: string[]; + /** Parsed manifest from commerce-app.json (if parseable). */ + manifest?: CommerceAppManifest; +} + +const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/; + +/** + * Validates a Commerce App Package (CAP) directory or zip file. + * + * Checks required files, manifest schema, and cartridge structure rules. + * This is a purely local operation — no B2C instance required. + * + * @param target - Path to a CAP directory or .zip file + * @returns Validation result with errors and warnings + * + * @example + * ```typescript + * const result = await validateCap('./my-commerce-app-v1.0.0'); + * if (!result.valid) { + * console.error('Validation errors:', result.errors); + * } + * ``` + */ +export async function validateCap(target: string): Promise { + const errors: string[] = []; + const warnings: string[] = []; + let manifest: CommerceAppManifest | undefined; + + if (!fs.existsSync(target)) { + return {valid: false, errors: [`Target not found: ${target}`], warnings}; + } + + const stat = fs.statSync(target); + let capDir: string; + let tempDir: string | undefined; + + if (stat.isDirectory()) { + capDir = target; + } else if (stat.isFile() && target.endsWith('.zip')) { + // Extract zip to temp dir for inspection + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-cap-validate-')); + try { + await extractZipToDir(target, tempDir); + // The zip root should be a single directory + const entries = fs.readdirSync(tempDir); + if (entries.length === 1 && fs.statSync(path.join(tempDir, entries[0])).isDirectory()) { + capDir = path.join(tempDir, entries[0]); + } else { + capDir = tempDir; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + cleanupTemp(tempDir); + return {valid: false, errors: [`Failed to read zip: ${msg}`], warnings}; + } + } else { + return {valid: false, errors: [`Target must be a directory or .zip file: ${target}`], warnings}; + } + + try { + // --- commerce-app.json --- + const manifestPath = path.join(capDir, 'commerce-app.json'); + if (!fs.existsSync(manifestPath)) { + errors.push('Missing required file: commerce-app.json'); + } else { + try { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record; + const requiredFields = ['id', 'name', 'version', 'domain'] as const; + for (const field of requiredFields) { + if (!raw[field] || typeof raw[field] !== 'string') { + errors.push(`commerce-app.json: missing or invalid field "${field}"`); + } + } + if (raw.version && typeof raw.version === 'string' && !SEMVER_RE.test(raw.version)) { + errors.push(`commerce-app.json: "version" must be a valid semver string (got "${raw.version}")`); + } + if (errors.filter((e) => e.startsWith('commerce-app.json:')).length === 0) { + manifest = raw as unknown as CommerceAppManifest; + } + // Check root dir naming convention + const dirName = path.basename(capDir); + if (manifest && dirName !== '.' && dirName !== tempDir) { + const expectedName = `${manifest.id}-v${manifest.version}`; + if (dirName !== expectedName) { + warnings.push(`Root directory "${dirName}" does not match expected convention "${expectedName}"`); + } + } + } catch { + errors.push('commerce-app.json: file exists but is not valid JSON'); + } + } + + // --- README.md --- + if (!fs.existsSync(path.join(capDir, 'README.md'))) { + errors.push('Missing required file: README.md'); + } + + // --- app-configuration/tasksList.json --- + const tasksListPath = path.join(capDir, 'app-configuration', 'tasksList.json'); + if (!fs.existsSync(tasksListPath)) { + errors.push('Missing required file: app-configuration/tasksList.json'); + } else { + try { + const tasks = JSON.parse(fs.readFileSync(tasksListPath, 'utf-8')) as unknown; + if (!Array.isArray(tasks)) { + errors.push('app-configuration/tasksList.json: must be a JSON array'); + } else { + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i] as Record; + for (const field of ['taskNumber', 'name', 'description', 'link']) { + if (!task[field]) { + errors.push(`app-configuration/tasksList.json: task[${i}] missing field "${field}"`); + } + } + } + } + } catch { + errors.push('app-configuration/tasksList.json: file exists but is not valid JSON'); + } + } + + // --- At least one substantive directory --- + const hasCartridges = fs.existsSync(path.join(capDir, 'cartridges')); + const hasStorefrontNext = fs.existsSync(path.join(capDir, 'storefront-next')); + const hasImpex = fs.existsSync(path.join(capDir, 'impex')); + if (!hasCartridges && !hasStorefrontNext && !hasImpex) { + errors.push('CAP must contain at least one of: cartridges/, storefront-next/, impex/'); + } + + // --- Cartridge rules --- + if (hasCartridges) { + const cartridgesDir = path.join(capDir, 'cartridges'); + validateCartridges(cartridgesDir, errors); + } + + // --- Optional warnings --- + if (!fs.existsSync(path.join(capDir, 'icons', 'icon.png'))) { + warnings.push('icons/icon.png not found (recommended for marketplace listing)'); + } + if (hasImpex && !fs.existsSync(path.join(capDir, 'impex', 'uninstall'))) { + warnings.push('impex/uninstall/ not found (recommended for clean removal)'); + } + } finally { + cleanupTemp(tempDir); + } + + return {valid: errors.length === 0, errors, warnings, manifest}; +} + +/** + * Validates cartridge structure and rules. + * - No pipeline/ directories + * - No *.ds pipeline descriptor files + * - Site cartridges must not have controllers/ + */ +function validateCartridges(cartridgesDir: string, errors: string[]): void { + // Check entire cartridges tree for pipelines + walkDir(cartridgesDir, (filePath, name, isDir) => { + if (isDir && name === 'pipeline') { + const rel = path.relative(cartridgesDir, filePath); + errors.push(`Pipelines not allowed in CAPs: cartridges/${rel}`); + } + if (!isDir && name.endsWith('.ds')) { + const rel = path.relative(cartridgesDir, filePath); + errors.push(`Pipeline descriptor files not allowed in CAPs: cartridges/${rel}`); + } + }); + + // Site cartridges must not contain controllers/ + const siteCartridgesDir = path.join(cartridgesDir, 'site_cartridges'); + if (fs.existsSync(siteCartridgesDir)) { + walkDir(siteCartridgesDir, (filePath, name, isDir) => { + if (isDir && name === 'controllers') { + const rel = path.relative(siteCartridgesDir, filePath); + errors.push( + `Site cartridges must not contain controllers/ (use BM cartridges instead): site_cartridges/${rel}`, + ); + } + }); + } +} + +/** + * Walks a directory tree, calling callback for each entry. + * Stops recursing into a directory if callback returns false. + */ +function walkDir(dir: string, cb: (filePath: string, name: string, isDir: boolean) => void): void { + if (!fs.existsSync(dir)) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, {withFileTypes: true}); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + cb(fullPath, entry.name, entry.isDirectory()); + if (entry.isDirectory()) { + walkDir(fullPath, cb); + } + } +} + +async function extractZipToDir(zipPath: string, destDir: string): Promise { + const data = await fs.promises.readFile(zipPath); + const zip = await JSZip.loadAsync(data); + for (const [relPath, entry] of Object.entries(zip.files)) { + const fullPath = path.join(destDir, relPath); + if (entry.dir) { + await fs.promises.mkdir(fullPath, {recursive: true}); + } else { + await fs.promises.mkdir(path.dirname(fullPath), {recursive: true}); + const content = await entry.async('nodebuffer'); + await fs.promises.writeFile(fullPath, content); + } + } +} + +function cleanupTemp(tempDir: string | undefined): void { + if (tempDir) { + try { + fs.rmSync(tempDir, {recursive: true, force: true}); + } catch { + // ignore cleanup errors + } + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index ce3d6607..eb034180 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -14,6 +14,7 @@ import * as path from 'node:path'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; +import {addDirectoryToZip} from '../util/zip.js'; import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from './run.js'; const IMPORT_JOB_ID = 'sfcc-site-archive-import'; @@ -176,8 +177,11 @@ export async function siteArchiveImport( body: {file_name: zipFilename} as unknown as string, }); - if (error || !data) { - // Try with parameters format as fallback + if ( + error?.fault?.type === 'UnknownPropertyException' && + (error.fault.arguments as Record)?.document === 'job_execution_request' + ) { + // Retry with parameters format (internal/support users) logger.warn('Retrying with parameters format for internal users'); const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { @@ -188,10 +192,12 @@ export async function siteArchiveImport( }); if (retryError || !retryData) { - throw new Error(retryError?.fault?.message ?? error?.fault?.message ?? 'Failed to execute import job'); + throw new Error(retryError?.fault?.message ?? 'Failed to execute import job'); } execution = retryData; + } else if (error || !data) { + throw new Error(error?.fault?.message ?? 'Failed to execute import job'); } else { execution = data; } @@ -245,25 +251,6 @@ async function createArchiveFromDirectory(dirPath: string, archiveDirName: strin }); } -/** - * Recursively adds directory contents to a JSZip folder. - */ -async function addDirectoryToZip(zipFolder: JSZip, dirPath: string): Promise { - const entries = await fs.promises.readdir(dirPath, {withFileTypes: true}); - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - - if (entry.isDirectory()) { - const subFolder = zipFolder.folder(entry.name)!; - await addDirectoryToZip(subFolder, fullPath); - } else if (entry.isFile()) { - const content = await fs.promises.readFile(fullPath); - zipFolder.file(entry.name, content); - } - } -} - /** * Wraps the contents of a zip buffer under a new top-level directory. * @@ -306,6 +293,7 @@ export interface ExportSitesConfiguration { all?: boolean; cache_settings?: boolean; campaigns_and_promotions?: boolean; + commerce_feature_states?: boolean; content?: boolean; coupons?: boolean; custom_objects?: boolean; @@ -451,7 +439,7 @@ export async function siteArchiveExport( let execution: JobExecution; // Execute export job - try export_file format first - try { + { const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { params: {path: {job_id: EXPORT_JOB_ID}}, body: { @@ -460,30 +448,33 @@ export async function siteArchiveExport( } as unknown as string, }); - if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to execute export job'); - } - - execution = data; - } catch { - // Try parameters format for internal users - logger.warn('Retrying with parameters format for internal users'); - - const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: EXPORT_JOB_ID}}, - body: { - parameters: [ - {name: 'ExportFile', value: zipFilename}, - {name: 'DataUnits', value: JSON.stringify(dataUnits)}, - ], - } as unknown as string, - }); + if ( + error?.fault?.type === 'UnknownPropertyException' && + (error.fault.arguments as Record)?.document === 'job_execution_request' + ) { + // Retry with parameters format (internal/support users) + logger.warn('Retrying with parameters format for internal users'); + + const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { + params: {path: {job_id: EXPORT_JOB_ID}}, + body: { + parameters: [ + {name: 'ExportFile', value: zipFilename}, + {name: 'DataUnits', value: JSON.stringify(dataUnits)}, + ], + } as unknown as string, + }); + + if (retryError || !retryData) { + throw new Error(retryError?.fault?.message ?? 'Failed to execute export job'); + } - if (error || !data) { + execution = retryData; + } else if (error || !data) { throw new Error(error?.fault?.message ?? 'Failed to execute export job'); + } else { + execution = data; } - - execution = data; } logger.debug({jobId: EXPORT_JOB_ID, executionId: execution.id}, `Export job started: ${execution.id}`); diff --git a/packages/b2c-tooling-sdk/src/operations/util/zip.ts b/packages/b2c-tooling-sdk/src/operations/util/zip.ts new file mode 100644 index 00000000..4114d266 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/util/zip.ts @@ -0,0 +1,25 @@ +/* + * 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 * as fs from 'node:fs'; +import * as path from 'node:path'; +import JSZip from 'jszip'; + +/** + * Recursively adds directory contents to a JSZip folder. + */ +export async function addDirectoryToZip(zipFolder: JSZip, dirPath: string): Promise { + const entries = await fs.promises.readdir(dirPath, {withFileTypes: true}); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + const subFolder = zipFolder.folder(entry.name)!; + await addDirectoryToZip(subFolder, fullPath); + } else if (entry.isFile()) { + const content = await fs.promises.readFile(fullPath); + zipFolder.file(entry.name, content); + } + } +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/README.md b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/README.md new file mode 100644 index 00000000..67bf3c0b --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/README.md @@ -0,0 +1,158 @@ +# Avalara Tax Integration for Commerce Cloud + +This cartridge integrates Avalara AvaTax with Salesforce Commerce Cloud to provide real-time tax calculation, address validation, and tax reporting capabilities. + +## Package manifest + +This package includes a root-level **`commerce-app.json`** manifest: app identity, domain, version, capabilities, cartridge and Storefront Next extension paths, and other extensible metadata. Post-install checklist tasks remain in **`app-configuration/tasksList.json`**. Tools and registries can read `commerce-app.json` for package-level metadata without relying on an external registry manifest alone. + +## Overview + +The Avalara Tax App provides: + +- Real-time tax calculation for orders +- Address validation for US and Canadian addresses +- Tax breakdown details (federal, state, etc.) +- Transaction management and commitment to AvaTax +- Support for both SFRA and SiteGenesis architectures + +## Cartridge Structure + +### Business Manager Cartridges + +- **bm_avatax**: Business Manager extensions for AvaTax configuration and management + +### Site Cartridges + +- **int_avatax**: Core AvaTax integration cartridge with controllers and scripts +- **int_avatax_sfra**: SFRA-specific extensions for AvaTax integration + +### Storefront Next + +- **storefront-next/src/extensions/avatax-tax-breakdown**: Tax breakdown component for Storefront Next + +## Installation + +### Prerequisites + +- Avalara AvaTax account with API credentials +- Company Code configured in AvaTax + +### Steps + +1. **Import Metadata and Services** + + ``` + impex/install/meta/system-objecttype-extensions.xml + impex/install/services.xml + impex/install/sites/SITEID/preferences.xml + ``` + +2. **Configure AvaTax Service Credentials** + - Navigate to Administration > Operations > Services + - Update `credentials.avatax.rest` with your AvaTax credentials: + - URL: Use sandbox URL for testing or production URL for live transactions + - User ID: Your AvaTax account ID + - Password: Your AvaTax license key + +3. **Configure Site Preferences** + - Navigate to Merchant Tools > Site Preferences > Custom Preferences > AvaTax + - Configure the following: + - **Enable Avatax**: Set to Yes to enable tax calculation + - **Enable Address Validation**: Enable address validation for US/Canada + - **Company Code**: Your AvaTax company code + - **Ship From Address**: Configure your origin address + - **Document Commit Settings**: Configure transaction commitment behavior + +4. **Upload Cartridges** + - Upload cartridges to your instance + - Add to cartridge path in site settings: + - For SFRA sites: `int_avatax_sfra:int_avatax:[other_cartridges]` + - For SiteGenesis sites: `int_avatax:[other_cartridges]` + - Add to Business Manager cartridge path: `bm_avatax:[other_bm_cartridges]` + +## Configuration + +### Site Preferences (AvaTax Group) + +| Preference | Description | Default | +| ---------------------------------- | -------------------------------------------------- | --------------- | +| **ATEnable** | Enable/disable AvaTax integration | true | +| **ATEnableAddressValidation** | Enable address validation for US/Canada | true | +| **ATEnableTesting** | Enable test controllers for transaction management | true | +| **AtDocumentCommitAllowed** | Save transactions to AvaTax | true | +| **AtCommitTransaction** | Auto-commit transactions on successful payment | false | +| **AtCompanyCode** | Your AvaTax company code | (required) | +| **ATCustomerCode** | Customer identifier to send to AvaTax | customer_number | +| **AtDefaultShippingMethodTaxCode** | Tax code for shipping | FR | +| **AtShipFromLocationCode** | AvaTax location code | (optional) | +| **AtShipFrom[Address Fields]** | Origin address for tax calculation | (required) | + +### Service Configuration + +The integration uses the following service: + +- **Service ID**: `avatax.rest.all` +- **Credential ID**: `credentials.avatax.rest` +- **Profile ID**: `profile.avatax.rest` + +## Features + +### Tax Calculation + +- Real-time tax calculation during checkout +- Line-item level tax breakdown +- Shipping tax calculation +- Promotion and adjustment handling + +### Address Validation + +- Validates US and Canadian addresses using AvaTax API +- Returns normalized addresses +- Provides address suggestions + +### Transaction Management + +- Create tax documents in AvaTax +- Commit transactions on order creation +- Void/cancel transactions for order cancellations +- Test controllers for transaction management (when enabled) + +### Custom Attributes + +The following custom attributes are added to the Basket object: + +- **commerceTaxApp_TaxDetails**: Complete tax breakdown information +- **commerceTaxApp_federal_tax_amount**: Federal tax amount +- **commerceTaxApp_state_tax_amount**: State tax amount + +## Testing + +When **ATEnableTesting** is enabled, test controllers/pipelines are available for: + +- Voiding transactions +- Modifying transactions +- Testing address validation +- Reviewing tax calculations + +## Uninstallation + +To uninstall the integration: + +1. Remove cartridges from cartridge paths +2. Import uninstall metadata: `impex/uninstall/` +3. Disable the AvaTax service + +## Support + +For AvaTax API documentation, visit: https://developer.avalara.com/ + +## Version History + +### v1.0.0 + +- Initial release +- Tax calculation for orders +- Address validation +- SFRA and SiteGenesis support +- Transaction commitment diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/app-configuration/tasksList.json b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/app-configuration/tasksList.json new file mode 100644 index 00000000..fb96af92 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/app-configuration/tasksList.json @@ -0,0 +1,26 @@ +[ + { + "name": "Verify Avalara Service Profile", + "description": "Confirm the Avalara service profile is configured correctly in Operations > Services > Profiles.", + "link": "/on/demandware.store/Sites-Site/default/ServiceProfile-DisplayAll", + "taskNumber": "1" + }, + { + "name": "Verify Avalara Service Credentials", + "description": "Confirm the Avalara service credentials are configured correctly in Operations > Services > Credentials.", + "link": "/on/demandware.store/Sites-Site/default/ServiceCredential-DisplayAll", + "taskNumber": "2" + }, + { + "name": "Verify Avalara Service Definition", + "description": "Confirm the Avalara service definition is configured correctly in Operations > Services.", + "link": "/on/demandware.store/Sites-Site/default/Service-DisplayAll", + "taskNumber": "3" + }, + { + "name": "Verify Custom Site Preferences", + "description": "Confirm Avalara custom site preferences (e.g. AvaTax preference group) in Merchant Tools > Site Preferences.", + "link": "/on/demandware.store/Sites-Site/default/ViewApplication-BM?SelectedMenuItem=site-prefs_custom_prefs#/?preference#site_preference_group_attributes!id!AvaTax", + "taskNumber": "4" + } +] diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/helpers/avataxHelper.js b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/helpers/avataxHelper.js new file mode 100644 index 00000000..db7ecfbb --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/helpers/avataxHelper.js @@ -0,0 +1,379 @@ +'use strict'; + +var Logger = require('dw/system/Logger'); +var Site = require('dw/system/Site'); + +var logger = Logger.getLogger('AvaTax', 'avataxHelper'); + +/** + * Gets AvaTax configuration from Site Custom Preferences + * @returns {Object} Configuration object + */ +function getConfig() { + var currentSite = Site.getCurrent(); + + return { + baseUrl: currentSite.getCustomPreferenceValue('ATServiceURL') || 'https://sandbox-rest.avatax.com', + accountId: currentSite.getCustomPreferenceValue('ATAccountID') || '1100046103', // TODO: Remove + licenseKey: currentSite.getCustomPreferenceValue('ATLicenseKey') || '8CC13BF3AE2B8122', // TODO: Remove + companyCode: currentSite.getCustomPreferenceValue('ATCompanyCode') || 'dwre', // TODO: Remove + enabled: currentSite.getCustomPreferenceValue('ATEnable') || true, // TODO: Make false + }; +} + +/** + * Builds the AvaTax CreateTransaction request payload + * @param {dw.order.LineItemCtnr} basket - The basket or order + * @returns {Object} Transaction model for AvaTax API + */ +function buildTransactionModel(basket) { + var lines = []; + var lineNumber = 0; + var currentSite = Site.getCurrent(); + + // Get ship-from address from site preferences + var shipFrom = { + locationCode: currentSite.getCustomPreferenceValue('ATShipFromLocationCode') || '', + line1: currentSite.getCustomPreferenceValue('ATShipFromLine1') || '5 Wall Street', + line2: currentSite.getCustomPreferenceValue('ATShipFromLine2') || '', + line3: currentSite.getCustomPreferenceValue('ATShipFromLine3') || '', + city: currentSite.getCustomPreferenceValue('ATShipFromCity') || 'Burlington', + region: currentSite.getCustomPreferenceValue('ATShipFromStateCode') || 'MA', + country: currentSite.getCustomPreferenceValue('ATShipFromCountryCode') || 'US', + postalCode: currentSite.getCustomPreferenceValue('ATShipFromZipCode') || '01803', + latitude: currentSite.getCustomPreferenceValue('ATShipFromLatitude') || '', + longitude: currentSite.getCustomPreferenceValue('ATShipFromLongitude') || '', + }; + + // Get default tax codes from site preferences + var defaultProductTaxCode = currentSite.getCustomPreferenceValue('ATDefaultProductTaxCode') || 'P0000000'; + var defaultShippingTaxCode = currentSite.getCustomPreferenceValue('ATDefaultShippingMethodTaxCode') || 'FR020100'; + + // Process product line items + var productLineItems = basket.getAllProductLineItems(); + var pliCount = productLineItems.length; + logger.warn('Processing ' + pliCount + ' product line items for basket: ' + basket.getUUID()); + + // Log all shipments in basket + var shipments = basket.getShipments(); + logger.warn('Basket has ' + shipments.length + ' shipments'); + for (var i = 0; i < shipments.length; i++) { + var s = shipments[i]; + var hasAddr = s.getShippingAddress() != null; + logger.warn( + 'Shipment[' + + i + + '] ID=' + + s.getID() + + ', hasAddress=' + + hasAddr + + (hasAddr ? ', city=' + s.getShippingAddress().city : ''), + ); + } + + var pliIterator = productLineItems.iterator(); + while (pliIterator.hasNext()) { + var pli = pliIterator.next(); + var shipment = pli.getShipment(); + + if (!shipment) { + logger.warn('Product line item has no shipment: ' + pli.productID); + continue; + } + + logger.warn('PLI ' + pli.productID + ' -> Shipment ' + shipment.getID()); + + if (!shipment.getShippingAddress()) { + logger.warn('Shipment has no shipping address: ' + pli.productID + ', shipmentID: ' + shipment.getID()); + continue; + } + + var shippingAddress = shipment.getShippingAddress(); + var shipTo = { + line1: shippingAddress.address1 || '', + line2: shippingAddress.address2 || '', + city: shippingAddress.city || '', + region: shippingAddress.stateCode || '', + country: shippingAddress.countryCode ? shippingAddress.countryCode.value : 'US', + postalCode: shippingAddress.postalCode || '', + }; + + lines.push({ + number: ++lineNumber, + quantity: pli.quantityValue, + amount: pli.adjustedGrossPrice.value, + taxCode: pli.getProduct() && pli.getProduct().taxClassID ? pli.getProduct().taxClassID : defaultProductTaxCode, + itemCode: pli.productID, + description: pli.productName.substring(0, 255), + addresses: { + shipFrom: shipFrom, + shipTo: shipTo, + }, + }); + } + + // Process shipping line items + var shipments = basket.getShipments().iterator(); + while (shipments.hasNext()) { + var shipment = shipments.next(); + var shippingAddress = shipment.getShippingAddress(); + + if (!shippingAddress) { + logger.warn('Shipment ' + shipment.getID() + ' has no shipping address, skipping shipping line items'); + continue; + } + + var shippingLineItems = shipment.getShippingLineItems().iterator(); + while (shippingLineItems.hasNext()) { + var sli = shippingLineItems.next(); + + // Only add shipping line item if it has a cost + var shippingAmount = sli.adjustedPrice ? sli.adjustedPrice.value : 0; + if (shippingAmount <= 0) { + logger.debug('Shipping line item ' + sli.ID + ' has zero cost, skipping'); + continue; + } + + var shipTo = { + line1: shippingAddress.address1 || '', + line2: shippingAddress.address2 || '', + city: shippingAddress.city || '', + region: shippingAddress.stateCode || '', + country: shippingAddress.countryCode ? shippingAddress.countryCode.value : 'US', + postalCode: shippingAddress.postalCode || '', + }; + + lines.push({ + number: ++lineNumber, + quantity: 1, + amount: shippingAmount, + taxCode: sli.taxClassID || defaultShippingTaxCode, + itemCode: sli.ID, + description: sli.lineItemText || 'Shipping', + addresses: { + shipFrom: shipFrom, + shipTo: shipTo, + }, + }); + + logger.debug('Added shipping line ' + lineNumber + ': $' + shippingAmount); + } + } + + // Build transaction model + var config = getConfig(); + + if (lines.length === 0) { + logger.warn( + 'No taxable line items found for basket ' + + basket.getUUID() + + '. This often happens on first address set before line items are properly associated with shipments.', + ); + } else { + logger.debug('Built transaction model with ' + lines.length + ' line items'); + } + + var transactionModel = { + type: 'SalesOrder', + companyCode: config.companyCode, + date: new Date().toISOString().split('T')[0], + customerCode: basket.getCustomerEmail() || 'guest-' + basket.getUUID(), + currencyCode: basket.getCurrencyCode(), + lines: lines, + commit: false, + }; + + return transactionModel; +} + +/** + * Calls AvaTax CreateTransaction API + * @param {Object} transactionModel - The transaction request payload + * @returns {Object} Response object with {success: boolean, data: Object, error: String} + */ +function callAvaTaxAPI(transactionModel) { + var HTTPClient = require('dw/net/HTTPClient'); + var httpClient = new HTTPClient(); + var config = getConfig(); + + // Check if AvaTax is enabled + if (!config.enabled) { + logger.warn('AvaTax is not enabled in site preferences'); + return { + success: false, + error: 'AvaTax not enabled', + }; + } + + // Check if credentials are configured + if (!config.accountId || !config.licenseKey) { + logger.error('AvaTax credentials not configured in site preferences'); + return { + success: false, + error: 'AvaTax credentials not configured. Please set ATAccountID and ATLicenseKey in Site Preferences.', + }; + } + + try { + var url = config.baseUrl + '/api/v2/transactions/create'; + + // Set up Basic Auth + var credentials = config.accountId + ':' + config.licenseKey; + var encodedCredentials = require('dw/util/StringUtils').encodeBase64(credentials); + + httpClient.open('POST', url); + httpClient.setRequestHeader('Authorization', 'Basic ' + encodedCredentials); + httpClient.setRequestHeader('Content-Type', 'application/json'); + + var requestBody = JSON.stringify(transactionModel); + logger.debug('AvaTax Request: ' + requestBody); + + httpClient.send(requestBody); + + var statusCode = httpClient.getStatusCode(); + var responseText = httpClient.getText(); + + logger.debug('AvaTax Response Status: ' + statusCode); + logger.debug('AvaTax Response: ' + responseText); + + if (statusCode === 200 || statusCode === 201) { + return { + success: true, + data: JSON.parse(responseText), + }; + } else { + var errorResponse = responseText ? JSON.parse(responseText) : {}; + return { + success: false, + error: 'AvaTax API error: ' + statusCode, + details: errorResponse, + }; + } + } catch (e) { + logger.error('AvaTax API call failed: ' + e.message); + return { + success: false, + error: e.message, + }; + } +} + +/** + * Applies AvaTax response to basket line items + * @param {dw.order.LineItemCtnr} basket - The basket + * @param {Object} avaTaxResponse - Response from AvaTax API + */ +function applyTaxesToBasket(basket, avaTaxResponse) { + if (!avaTaxResponse || !avaTaxResponse.lines) { + logger.warn('No tax lines in AvaTax response'); + return; + } + + var Money = require('dw/value/Money'); + var lineMap = {}; + + logger.warn('===== APPLYING TAXES TO BASKET ====='); + logger.warn('AvaTax returned ' + avaTaxResponse.lines.length + ' tax lines'); + + // Create a map of line numbers to tax amounts + avaTaxResponse.lines.forEach(function (line) { + // Calculate effective tax rate from AvaTax response + var effectiveRate = 0; + if (line.taxableAmount && line.taxableAmount > 0) { + effectiveRate = line.tax / line.taxableAmount; + } + + lineMap[line.lineNumber] = { + tax: line.tax, + rate: effectiveRate, + taxableAmount: line.taxableAmount || 0, + }; + logger.warn( + 'AvaTax line ' + + line.lineNumber + + ': tax=$' + + line.tax + + ', taxableAmount=$' + + line.taxableAmount + + ', calculated rate=' + + effectiveRate.toFixed(4), + ); + }); + + var lineNumber = 0; + + // Apply to product line items + var productLineItems = basket.getAllProductLineItems().iterator(); + logger.warn('Applying taxes to product line items...'); + + while (productLineItems.hasNext()) { + var pli = productLineItems.next(); + lineNumber++; + + logger.warn('Processing PLI ' + pli.productID + ' as line number ' + lineNumber); + + if (lineMap[lineNumber]) { + var taxInfo = lineMap[lineNumber]; + var taxMoney = new Money(taxInfo.tax, basket.getCurrencyCode()); + logger.warn('Setting tax on PLI: $' + taxInfo.tax + ' at rate ' + taxInfo.rate); + pli.setTax(taxMoney); + pli.updateTax(taxInfo.rate); + logger.warn('After setTax - PLI tax: $' + pli.getTax().value + ', taxRate: ' + pli.getTaxRate()); + } else { + logger.warn('No tax mapping found for line number ' + lineNumber); + } + } + + // Apply to shipping line items + logger.warn('Applying taxes to shipping line items...'); + var shipments = basket.getShipments().iterator(); + while (shipments.hasNext()) { + var shipment = shipments.next(); + var shippingLineItems = shipment.getShippingLineItems().iterator(); + + while (shippingLineItems.hasNext()) { + var sli = shippingLineItems.next(); + + // Only process if shipping has a cost + var shippingAmount = sli.adjustedPrice ? sli.adjustedPrice.value : 0; + if (shippingAmount <= 0) { + continue; + } + + lineNumber++; + logger.warn('Processing shipping line item ' + sli.ID + ' as line number ' + lineNumber); + + if (lineMap[lineNumber]) { + var taxInfo = lineMap[lineNumber]; + var taxMoney = new Money(taxInfo.tax, basket.getCurrencyCode()); + logger.warn('Setting tax on shipping: $' + taxInfo.tax + ' at rate ' + taxInfo.rate); + sli.setTax(taxMoney); + sli.updateTax(taxInfo.rate); + logger.warn('After setTax - Shipping tax: $' + sli.getTax().value + ', taxRate: ' + sli.getTaxRate()); + } else { + logger.warn('No tax mapping found for shipping line number ' + lineNumber); + } + } + } + + // Recalculate basket totals after setting line item taxes + logger.warn('Calling basket.updateTotals()...'); + basket.updateTotals(); + + logger.warn('After updateTotals():'); + logger.warn(' - basket.taxTotal: $' + (basket.getTotalTax() ? basket.getTotalTax().value : 'null')); + logger.warn( + ' - basket.merchandizeTotalTax: $' + + (basket.getMerchandizeTotalTax() ? basket.getMerchandizeTotalTax().value : 'null'), + ); + logger.warn( + ' - basket.shippingTotalTax: $' + (basket.getShippingTotalTax() ? basket.getShippingTotalTax().value : 'null'), + ); + logger.warn('===== FINISHED APPLYING TAXES ====='); +} + +module.exports = { + buildTransactionModel: buildTransactionModel, + callAvaTaxAPI: callAvaTaxAPI, + applyTaxesToBasket: applyTaxesToBasket, +}; diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks.json b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks.json new file mode 100644 index 00000000..a6d3cd77 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": [ + { + "name": "dw.apps.checkout.tax.calculate", + "script": "./hooks/calculate.js" + }, + { + "name": "dw.apps.checkout.tax.commit", + "script": "./hooks/commit.js" + }, + { + "name": "dw.apps.checkout.tax.cancel", + "script": "./hooks/cancel.js" + } + ] +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/calculate.js b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/calculate.js new file mode 100644 index 00000000..eb51a645 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/calculate.js @@ -0,0 +1,136 @@ +/* eslint-disable */ +'use strict'; + +var Logger = require('dw/system/Logger'); +var Status = require('dw/system/Status'); +var Transaction = require('dw/system/Transaction'); +var avataxHelper = require('~/cartridge/scripts/helpers/avataxHelper'); + +var logger = Logger.getLogger('AvaTax', 'calculate'); + +/** + * Calculate taxes using AvaTax API. + * Calls AvaTax sandbox to get real-time tax calculation and stores custom tax details. + * + * @param {dw.order.LineItemCtnr} lineItemCtnr - The basket or order to calculate taxes for + * @returns {dw.system.Status} Status indicating success or failure + */ +exports.calculate = function (lineItemCtnr) { + logger.warn('Starting AvaTax tax calculation for basket: ' + lineItemCtnr.getUUID()); + + try { + // Set custom fields for tax app metadata + Transaction.wrap(function () { + lineItemCtnr.custom.commerceTaxApp_TaxDetails = 'AvaTax: Real-time tax calculation'; + lineItemCtnr.custom.commerceTaxApp_federal_tax_amount = '0.00'; // Will be updated after API call + lineItemCtnr.custom.commerceTaxApp_state_tax_amount = '0.00'; // Will be updated after API call + + // Force basket to recalculate and sync line item-shipment associations + // This ensures shipping addresses are properly associated with line items + // before we build the AvaTax transaction model + lineItemCtnr.updateTotals(); + }); + + // Build AvaTax transaction model + var transactionModel = avataxHelper.buildTransactionModel(lineItemCtnr); + + if (!transactionModel.lines || transactionModel.lines.length === 0) { + logger.warn('No taxable line items found in basket'); + return new Status(Status.OK, 'No taxable items'); + } + + logger.warn('Calling AvaTax API with ' + transactionModel.lines.length + ' line items'); + + // Call AvaTax API + var response = avataxHelper.callAvaTaxAPI(transactionModel); + + if (!response.success) { + logger.error('AvaTax API call failed: ' + response.error); + + // Fall back to zero tax on error + Transaction.wrap(function () { + setZeroTax(lineItemCtnr); + lineItemCtnr.custom.commerceTaxApp_TaxDetails = 'AvaTax Error: ' + response.error; + }); + + return new Status(Status.OK, 'Tax calculation failed, applied zero tax'); + } + + // Apply taxes from AvaTax response + Transaction.wrap(function () { + // Apply individual line item taxes and recalculate basket totals + avataxHelper.applyTaxesToBasket(lineItemCtnr, response.data); + + // Update custom fields with tax breakdown + var totalTax = response.data.totalTax || 0; + var stateTax = calculateStateTax(response.data); + var federalTax = totalTax - stateTax; + + lineItemCtnr.custom.commerceTaxApp_federal_tax_amount = federalTax.toFixed(2); + lineItemCtnr.custom.commerceTaxApp_state_tax_amount = stateTax.toFixed(2); + lineItemCtnr.custom.commerceTaxApp_TaxDetails = + 'AvaTax: Total Tax $' + + totalTax.toFixed(2) + + ' (Federal: $' + + federalTax.toFixed(2) + + ', State: $' + + stateTax.toFixed(2) + + ')'; + }); + + logger.warn('AvaTax tax calculation completed successfully. Total tax: $' + response.data.totalTax); + return new Status(Status.OK, 'Taxes calculated via AvaTax'); + } catch (e) { + logger.error('Error during tax calculation: ' + e.message + '\n' + e.stack); + + // Fall back to zero tax on exception + Transaction.wrap(function () { + setZeroTax(lineItemCtnr); + lineItemCtnr.custom.commerceTaxApp_TaxDetails = 'AvaTax Exception: ' + e.message; + }); + + return new Status(Status.ERROR, 'Tax calculation exception'); + } +}; + +/** + * Sets all line items to zero tax (fallback for errors) + * @param {dw.order.LineItemCtnr} lineItemCtnr - The basket or order + */ +function setZeroTax(lineItemCtnr) { + var Money = require('dw/value/Money'); + var zeroTax = new Money(0, lineItemCtnr.getCurrencyCode()); + + var lineItems = lineItemCtnr.getAllLineItems().iterator(); + while (lineItems.hasNext()) { + var lineItem = lineItems.next(); + try { + lineItem.setTax(zeroTax); + lineItem.updateTax(0); + } catch (e) { + // Some line items may not support tax (e.g., price adjustments) + } + } +} + +/** + * Calculates state tax from AvaTax response + * @param {Object} avaTaxResponse - The AvaTax API response + * @returns {Number} Total state tax amount + */ +function calculateStateTax(avaTaxResponse) { + var stateTax = 0; + + if (!avaTaxResponse.summary || !avaTaxResponse.summary.length) { + return stateTax; + } + + avaTaxResponse.summary.forEach(function (jurisdiction) { + // State-level jurisdictions + if (jurisdiction.jurisType === 'State' || jurisdiction.jurisType === 'STA') { + stateTax += jurisdiction.tax || 0; + } + }); + + return stateTax; +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/cancel.js b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/cancel.js new file mode 100644 index 00000000..98ab7c58 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/cancel.js @@ -0,0 +1,38 @@ +/* eslint-disable */ +'use strict'; + +importPackage(dw.system); +importPackage(dw.order); + +var Logger = require('dw/system/Logger'); +var Status = require('dw/system/Status'); + +var demoAppLogger = Logger.getLogger('demoApp', 'cancel'); + +/** + * Void tax transaction with external provider (e.g., Avalara). + * This is called when an order is cancelled or failed. + * Voids the previously committed tax transaction. + */ +exports.cancel = function (order) { + try { + demoAppLogger.warn('Starting cancel.js for order: ' + order.getOrderNo()); + + // Get the transaction ID that was stored during commit + var transactionId = order.getCustom().get('taxProviderTransactionId'); + + // Simulate API call to external tax provider to void transaction + // In real implementation, this would call Avalara's void API: + // var avalaraService = require('~/cartridge/scripts/tax/AvalaraService'); + // var result = avalaraService.voidTransaction(transactionId); + + // Mark order as voided + order.getCustom().put('taxProviderVoided', 'true'); + + demoAppLogger.info('Tax transaction voided: ' + transactionId); + return new Status(Status.OK, 'Tax transaction voided: ' + transactionId); + } catch (e) { + demoAppLogger.error('Failed to void tax: ' + e.message); + return new Status(Status.ERROR, 'TAX_VOID_FAILED', 'Failed to void tax: ' + e.message); + } +}; diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/commit.js b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/commit.js new file mode 100644 index 00000000..40da2963 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/cartridge/scripts/hooks/commit.js @@ -0,0 +1,38 @@ +/* eslint-disable */ +'use strict'; + +importPackage(dw.system); +importPackage(dw.order); + +var Logger = require('dw/system/Logger'); +var Status = require('dw/system/Status'); + +var demoAppLogger = Logger.getLogger('demoApp', 'commit'); + +/** + * Commit tax transaction with external provider (e.g., Avalara). + * This is called after an order is successfully placed. + * Records the transaction ID from the tax provider for audit purposes. + */ +exports.commit = function (order) { + try { + demoAppLogger.warn('Starting commit.js for order: ' + order.getOrderNo()); + + // Simulate API call to external tax provider to commit transaction + // In real implementation, this would call Avalara's commit API: + // var avalaraService = require('~/cartridge/scripts/tax/AvalaraService'); + // var result = avalaraService.commitTransaction(order); + + // Generate mock transaction ID + var transactionId = 'AVALARA-TX-12345'; + + // Store transaction ID on order for future reference (voids, adjustments, etc.) + order.getCustom().put('taxProviderTransactionId', transactionId); + + demoAppLogger.info('Tax transaction committed: ' + transactionId); + return new Status(Status.OK, 'Tax transaction committed: ' + transactionId); + } catch (e) { + demoAppLogger.error('Failed to commit tax: ' + e.message); + return new Status(Status.ERROR, 'TAX_COMMIT_FAILED', 'Failed to commit tax: ' + e.message); + } +}; diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/package.json b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/package.json new file mode 100644 index 00000000..52276142 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/cartridges/site_cartridges/int_avatax/package.json @@ -0,0 +1,3 @@ +{ + "hooks": "./cartridge/scripts/hooks.json" +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/commerce-app.json b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/commerce-app.json new file mode 100644 index 00000000..ec772e09 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/commerce-app.json @@ -0,0 +1,13 @@ +{ + "id": "avalara-tax", + "name": "Avalara Tax", + "description": "Automated tax compliance solution by Avalara", + "domain": "tax", + "version": "0.2.5", + "publisher": { + "name": "Avalara", + "url": "https://developer.avalara.com/", + "support": "https://developer.avalara.com/" + }, + "dependencies": {} +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/meta/system-objecttype-extensions.xml b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/meta/system-objecttype-extensions.xml new file mode 100644 index 00000000..2eb2ff11 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/meta/system-objecttype-extensions.xml @@ -0,0 +1,201 @@ + + + + + + + Tax Details + string + false + false + + + Tax Details + string + false + false + + + Tax Details + string + false + false + + + + + + + Enable Avatax + boolean + false + false + true + + + Enable Address Validation + AvaTax only sends United States and Canadian addresses for validation + boolean + false + false + true + + + Enable Test Pipelines / Controllers + Set this as Yes for voiding, modifying the transactions + boolean + false + false + true + + + On successful payment commit transaction to AvaTax + Default 'No'. If 'Yes', successful orders will be marked Committed while posting to AvaTax. + boolean + false + false + false + + + Avatax Company Code + string + false + false + 0 + + + Avatax Default Shipping Method Tax Code (Tax) + If left blank the Default Value FR will be used + string + false + false + 0 + FR + + + Save transactions to AvaTax + Default 'Yes'. Transactions are being saved at Avatax. When set to 'No', transactions are not saved at AvaTax, and features such as voiding, modifying the transactions are not available on the storefront. + boolean + false + false + true + + + Custom attribute for customer code + This field is mandatory if "Use another attribute from 'Profile' System object" option is selected as customer code. Enter only the attribute name. e.g. taxID. If it is a custom attribute of Profile object, add 'custom' accessor. e.g. custom.customattr + string + false + false + 0 + + + Customer Code + Customer code to be used from authenticated customers for transactions posted to AvaTax. + enum-of-string + false + false + + + Customer number + customer_number + + + Customer Email ID + customer_email + + + Use another attribute from 'Profile' System object + custom_attribute + + + + + Avatax ShipFrom City + string + false + false + 0 + 0 + 0 + + + Avatax ShipFrom Country Code + string + false + false + 0 + 0 + 0 + + + AvaTax ShipFrom address line 1 + string + false + false + 0 + + + AvaTax ShipFrom address line 2 + string + false + false + 0 + + + AvaTax ShipFrom address line 3 + string + false + false + 0 + + + AvaTax ShipFrom location code + Created in your AvaTax account + string + false + false + 0 + + + Avatax ShipFrom StateCode + string + false + false + 0 + 0 + 0 + + + Avatax ShipFrom ZipCode + string + false + false + 0 + 0 + 0 + + + + + AvaTax + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/services.xml b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/services.xml new file mode 100644 index 00000000..0fc5629c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/services.xml @@ -0,0 +1,28 @@ + + + + https://sandbox-rest.avatax.com/ + avataxuser + password + + + + 20000 + false + 0 + 0 + false + 0 + 0 + + + + HTTP + true + true + false + false + profile.avatax.rest + credentials.avatax.rest + + diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/sites/SITEID/preferences.xml b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/sites/SITEID/preferences.xml new file mode 100644 index 00000000..cb488314 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/install/sites/SITEID/preferences.xml @@ -0,0 +1,30 @@ + + + + + + + 1 + 1 + 1 + 0 + 1 + + + + FR + + + + + + + + + + + + customer_number + + + diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/uninstall/services.xml b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/uninstall/services.xml new file mode 100644 index 00000000..edfb2f60 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/impex/uninstall/services.xml @@ -0,0 +1,8 @@ + + + + + + GENERIC + + diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/storefront-next/src/extensions/avalara-tax/components/tax-breakdown.tsx b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/storefront-next/src/extensions/avalara-tax/components/tax-breakdown.tsx new file mode 100644 index 00000000..3df5e966 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/storefront-next/src/extensions/avalara-tax/components/tax-breakdown.tsx @@ -0,0 +1,74 @@ +/** + * Copyright 2026 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use client'; + +import {useTranslation} from 'react-i18next'; +import {useBasket} from '@/providers/basket'; +import {useCurrency} from '@/providers/currency'; +import {formatCurrency} from '@/lib/currency'; + +/** + * Avalara Tax Breakdown Component + * + * This component replaces the default tax line item in the order summary + * with separate state tax and federal tax line items. + * + * Note: This is an example implementation. In a real scenario, you would + * integrate with Avalara API to get the actual tax breakdown. For this + * example, we use a simple 60/40 split (60% state, 40% federal). + */ +export default function TaxBreakdown() { + const {t, i18n} = useTranslation('cart'); + const currency = useCurrency(); + const basket = useBasket(); + + // If no basket, don't render anything + if (!basket) { + return null; + } + + const taxTotal = basket.taxTotal; + + // Handle cases where tax is not available or is TBD + if (typeof taxTotal !== 'number' || taxTotal < 0) { + return ( +
+ {t('summary.tax')} + {t('summary.taxTbd')} +
+ ); + } + + // Example tax breakdown: 60% state, 40% federal + // In a real implementation, this would come from Avalara API + const stateTax = taxTotal * 0.6; + const federalTax = taxTotal * 0.4; + + return ( + <> + {/* State Tax */} +
+ State Tax + {formatCurrency(stateTax, i18n.language, currency)} +
+ {/* Federal Tax */} +
+ Federal Tax + {formatCurrency(federalTax, i18n.language, currency)} +
+ + ); +} diff --git a/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/storefront-next/src/extensions/avalara-tax/target-config.json b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/storefront-next/src/extensions/avalara-tax/target-config.json new file mode 100644 index 00000000..fe269544 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/fixtures/commerce-avalara-tax-app-v0.2.5/storefront-next/src/extensions/avalara-tax/target-config.json @@ -0,0 +1,9 @@ +{ + "components": [ + { + "targetId": "checkout.orderSummary.tax", + "path": "extensions/avalara-tax/components/tax-breakdown.tsx", + "order": 0 + } + ] +} diff --git a/packages/b2c-tooling-sdk/test/operations/cap/validate.test.ts b/packages/b2c-tooling-sdk/test/operations/cap/validate.test.ts new file mode 100644 index 00000000..c6dd363c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/cap/validate.test.ts @@ -0,0 +1,239 @@ +/* + * 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 * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {validateCap} from '../../../src/operations/cap/validate.js'; + +const FIXTURE_CAP = path.join( + path.dirname(new URL(import.meta.url).pathname), + '../../fixtures/commerce-avalara-tax-app-v0.2.5', +); + +/** Build a minimal valid CAP in a temp directory, then call the callback */ +async function withTempCap(setup: (dir: string) => void, callback: (dir: string) => Promise): Promise { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-cap-validate-')); + try { + setup(tempDir); + await callback(tempDir); + } finally { + fs.rmSync(tempDir, {recursive: true, force: true}); + } +} + +function writeMinimalCap(dir: string, overrides: {manifest?: Record} = {}): void { + const manifest = { + id: 'test-app', + name: 'Test App', + version: '1.0.0', + domain: 'tax', + ...overrides.manifest, + }; + fs.writeFileSync(path.join(dir, 'commerce-app.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(dir, 'README.md'), '# Test App'); + fs.mkdirSync(path.join(dir, 'app-configuration')); + const tasks = [{taskNumber: '1', name: 'Step 1', description: 'Do step 1', link: '/bm/step1'}]; + fs.writeFileSync(path.join(dir, 'app-configuration', 'tasksList.json'), JSON.stringify(tasks)); + fs.mkdirSync(path.join(dir, 'impex', 'install'), {recursive: true}); +} + +describe('operations/cap/validate', () => { + describe('validateCap', () => { + it('should validate the sample Avalara CAP fixture successfully', async () => { + if (!fs.existsSync(FIXTURE_CAP)) { + console.warn('Skipping fixture test — fixture not found:', FIXTURE_CAP); + return; + } + const result = await validateCap(FIXTURE_CAP); + expect(result.errors).to.deep.equal([]); + expect(result.valid).to.be.true; + expect(result.manifest).to.include({id: 'avalara-tax', domain: 'tax', version: '0.2.5'}); + }); + + it('should error when target does not exist', async () => { + const result = await validateCap('/nonexistent/path'); + expect(result.valid).to.be.false; + expect(result.errors[0]).to.include('not found'); + }); + + it('should error when commerce-app.json is missing', async () => { + await withTempCap( + (dir) => { + fs.writeFileSync(path.join(dir, 'README.md'), '# Test'); + fs.mkdirSync(path.join(dir, 'app-configuration')); + fs.writeFileSync( + path.join(dir, 'app-configuration', 'tasksList.json'), + JSON.stringify([{taskNumber: '1', name: 'x', description: 'x', link: '/x'}]), + ); + fs.mkdirSync(path.join(dir, 'impex'), {recursive: true}); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('commerce-app.json'))).to.be.true; + }, + ); + }); + + it('should error when README.md is missing', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + fs.unlinkSync(path.join(dir, 'README.md')); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('README.md'))).to.be.true; + }, + ); + }); + + it('should error when tasksList.json is missing', async () => { + await withTempCap( + (dir) => { + fs.writeFileSync( + path.join(dir, 'commerce-app.json'), + JSON.stringify({id: 'a', name: 'a', version: '1.0.0', domain: 'tax'}), + ); + fs.writeFileSync(path.join(dir, 'README.md'), '# test'); + fs.mkdirSync(path.join(dir, 'impex'), {recursive: true}); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('tasksList.json'))).to.be.true; + }, + ); + }); + + it('should error when no substantive directory is present', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + fs.rmSync(path.join(dir, 'impex'), {recursive: true, force: true}); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('at least one'))).to.be.true; + }, + ); + }); + + it('should error for site cartridges with controllers/', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + const siteCartridgeDir = path.join( + dir, + 'cartridges', + 'site_cartridges', + 'int_myapp', + 'cartridge', + 'controllers', + ); + fs.mkdirSync(siteCartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(siteCartridgeDir, 'MyController.js'), '// controller'); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('controllers'))).to.be.true; + }, + ); + }); + + it('should error when pipeline/ directory is present', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + const pipelineDir = path.join(dir, 'cartridges', 'site_cartridges', 'int_myapp', 'cartridge', 'pipeline'); + fs.mkdirSync(pipelineDir, {recursive: true}); + fs.writeFileSync(path.join(pipelineDir, 'Start.xml'), ''); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('pipeline'))).to.be.true; + }, + ); + }); + + it('should error when .ds pipeline file is present', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + const cartDir = path.join(dir, 'cartridges', 'site_cartridges', 'int_myapp', 'cartridge'); + fs.mkdirSync(cartDir, {recursive: true}); + fs.writeFileSync(path.join(cartDir, 'Start.ds'), ''); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('.ds'))).to.be.true; + }, + ); + }); + + it('should warn when icons/icon.png is missing', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.true; + expect(result.warnings.some((w) => w.includes('icon.png'))).to.be.true; + }, + ); + }); + + it('should warn when impex/uninstall/ is missing', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + // impex/install exists but no uninstall + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.true; + expect(result.warnings.some((w) => w.includes('uninstall'))).to.be.true; + }, + ); + }); + + it('should error when manifest version is not semver', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir, {manifest: {version: 'not-semver'}}); + }, + async (dir) => { + const result = await validateCap(dir); + expect(result.valid).to.be.false; + expect(result.errors.some((e) => e.includes('semver'))).to.be.true; + }, + ); + }); + + it('should allow controllers in bm_cartridges', async () => { + await withTempCap( + (dir) => { + writeMinimalCap(dir); + const bmControllers = path.join(dir, 'cartridges', 'bm_cartridges', 'bm_myapp', 'cartridge', 'controllers'); + fs.mkdirSync(bmControllers, {recursive: true}); + fs.writeFileSync(path.join(bmControllers, 'BM_Controller.js'), '// bm controller'); + }, + async (dir) => { + const result = await validateCap(dir); + // BM cartridge controllers are allowed + expect(result.errors.some((e) => e.includes('controllers'))).to.be.false; + }, + ); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/tsconfig.json b/packages/b2c-tooling-sdk/test/tsconfig.json index 8c7682ef..2e84f26c 100644 --- a/packages/b2c-tooling-sdk/test/tsconfig.json +++ b/packages/b2c-tooling-sdk/test/tsconfig.json @@ -8,5 +8,6 @@ "@salesforce/b2c-tooling-sdk/*": ["../src/*/index.ts", "../src/*"] } }, - "include": ["./**/*", "../src/**/*", "../../../types/**/*"] + "include": ["./**/*", "../src/**/*", "../../../types/**/*"], + "exclude": ["./fixtures/**/*.tsx"] } diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index ec3be35e..c9132372 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -28,7 +28,9 @@ "onView:b2cApiBrowser", "onView:b2cSandboxExplorer", "onCommand:b2c-dx.scaffold.generate", - "onDebugResolve:b2c-script" + "onCommand:b2c-dx.cap.install", + "onDebugResolve:b2c-script", + "workspaceContains:**/commerce-app.json" ], "main": "./dist/extension.js", "contributes": { @@ -65,6 +67,11 @@ "default": true, "description": "Enable the API Browser for exploring SCAPI schemas." }, + "b2c-dx.features.cap": { + "type": "boolean", + "default": true, + "description": "Enable Commerce App Package (CAP) commands and file decorations." + }, "b2c-dx.logLevel": { "type": "string", "default": "info", @@ -453,6 +460,12 @@ "command": "b2c-dx.resetProjectRoot", "title": "Reset B2C Commerce Root to Auto-Detect", "category": "B2C DX" + }, + { + "command": "b2c-dx.cap.install", + "title": "Install Commerce App (CAP)", + "icon": "$(cloud-upload)", + "category": "B2C DX" } ], "menus": { @@ -659,6 +672,11 @@ "command": "b2c-dx.content.import", "when": "explorerResourceIsFolder", "group": "2_import" + }, + { + "command": "b2c-dx.cap.install", + "when": "explorerResourceIsFolder && resource in b2c-dx.capDirectories", + "group": "3_cap" } ], "commandPalette": [ @@ -761,6 +779,10 @@ { "command": "b2c-dx.setProjectRoot", "when": "false" + }, + { + "command": "b2c-dx.cap.install", + "when": "false" } ] } diff --git a/packages/b2c-vs-extension/src/cap/cap-commands.ts b/packages/b2c-vs-extension/src/cap/cap-commands.ts new file mode 100644 index 00000000..9310c52f --- /dev/null +++ b/packages/b2c-vs-extension/src/cap/cap-commands.ts @@ -0,0 +1,94 @@ +/* + * 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 {commerceAppInstall, JobExecutionError} from '@salesforce/b2c-tooling-sdk/operations/cap'; +import {getJobLog} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; +import {openJobLog} from '../job-log-viewer.js'; + +async function showJobError(err: unknown, instance: B2CInstance, label: string): Promise { + if (err instanceof JobExecutionError && err.execution.is_log_file_existing) { + try { + const log = await getJobLog(instance, err.execution); + await openJobLog(err.execution.id ?? 'job', log); + } catch { + // Fall through to generic error + } + } + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`${label}: ${message}`); +} + +export function registerCapCommands( + _context: vscode.ExtensionContext, + configProvider: B2CExtensionConfig, +): vscode.Disposable[] { + const installCap = vscode.commands.registerCommand('b2c-dx.cap.install', async (uri?: vscode.Uri) => { + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('No B2C Commerce instance configured.'); + return; + } + + // Determine CAP path from context menu URI or file picker + let capPath: string; + if (uri) { + capPath = uri.fsPath; + } else { + const folders = await vscode.window.showOpenDialog({ + title: 'Select Commerce App Package (CAP) directory', + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Install CAP', + }); + if (!folders?.length) return; + capPath = folders[0].fsPath; + } + + // Prompt for site ID + const siteId = await vscode.window.showInputBox({ + title: 'Install Commerce App', + prompt: 'Enter the Site ID to install the Commerce App on', + placeHolder: 'e.g., RefArch', + validateInput: (value: string) => { + if (!value.trim()) return 'Site ID is required'; + return null; + }, + }); + if (!siteId) return; + + const capName = path.basename(capPath); + + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Installing Commerce App: ${capName}`, + cancellable: false, + }, + async (progress) => { + await commerceAppInstall(instance, capPath, { + siteId, + waitOptions: { + onPoll: (info) => { + progress.report({message: `Status: ${info.status}`}); + }, + }, + }); + }, + ); + + vscode.window.showInformationMessage(`Commerce App "${capName}" installed successfully on site "${siteId}".`); + } catch (err) { + await showJobError(err, instance, 'Commerce App install failed'); + } + }); + + return [installCap]; +} diff --git a/packages/b2c-vs-extension/src/cap/cap-decorator.ts b/packages/b2c-vs-extension/src/cap/cap-decorator.ts new file mode 100644 index 00000000..112b6afc --- /dev/null +++ b/packages/b2c-vs-extension/src/cap/cap-decorator.ts @@ -0,0 +1,49 @@ +/* + * 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 * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** + * Provides file decorations for Commerce App Package (CAP) directories. + * + * Any directory containing a `commerce-app.json` file gets a "CA" badge + * in the VS Code explorer, making CAPs visually distinct. + */ +export class CapFileDecorationProvider implements vscode.FileDecorationProvider { + private readonly _onDidChangeFileDecorations = new vscode.EventEmitter(); + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { + // Only decorate directories + try { + const stat = fs.statSync(uri.fsPath); + if (!stat.isDirectory()) return undefined; + } catch { + return undefined; + } + + const capJsonPath = path.join(uri.fsPath, 'commerce-app.json'); + if (fs.existsSync(capJsonPath)) { + return { + badge: 'CA', + tooltip: 'Commerce App Package (CAP)', + color: new vscode.ThemeColor('charts.blue'), + }; + } + + return undefined; + } + + /** Notify VS Code to re-evaluate decorations for all files. */ + refresh(): void { + this._onDidChangeFileDecorations.fire(undefined); + } + + dispose(): void { + this._onDidChangeFileDecorations.dispose(); + } +} diff --git a/packages/b2c-vs-extension/src/cap/index.ts b/packages/b2c-vs-extension/src/cap/index.ts new file mode 100644 index 00000000..929ce50b --- /dev/null +++ b/packages/b2c-vs-extension/src/cap/index.ts @@ -0,0 +1,70 @@ +/* + * 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 * as path from 'path'; +import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; +import {CapFileDecorationProvider} from './cap-decorator.js'; +import {registerCapCommands} from './cap-commands.js'; + +const CAP_DIRECTORIES_CONTEXT_KEY = 'b2c-dx.capDirectories'; + +/** + * Registers Commerce App Package (CAP) features: + * - File decorator: adds "CA" badge to CAP directories in the explorer + * - Context key: tracks CAP directories so the context menu only appears on CAP folders + * - Install command: right-click a CAP folder to install it + */ +export function registerCap(context: vscode.ExtensionContext, configProvider: B2CExtensionConfig): void { + // File decoration provider + const decorator = new CapFileDecorationProvider(); + context.subscriptions.push(vscode.window.registerFileDecorationProvider(decorator)); + context.subscriptions.push(decorator); + + // Track known CAP directory URIs for context menu visibility. + // The `in` operator in VS Code `when` clauses checks for keys in an object. + // The `resource` when-clause variable evaluates to the full URI string (file:///...). + const capUris = new Set(); + + function updateCapContext(): void { + const obj: Record = {}; + for (const u of capUris) { + obj[u] = true; + } + vscode.commands.executeCommand('setContext', CAP_DIRECTORIES_CONTEXT_KEY, obj); + } + + async function scanCapDirectories(): Promise { + capUris.clear(); + const files = await vscode.workspace.findFiles('**/commerce-app.json'); + for (const f of files) { + const dirUri = vscode.Uri.file(path.dirname(f.fsPath)); + capUris.add(dirUri.toString()); + } + updateCapContext(); + } + + // Initial scan + void scanCapDirectories(); + + // Watch for commerce-app.json changes to update decorations and context key + const watcher = vscode.workspace.createFileSystemWatcher('**/commerce-app.json'); + watcher.onDidCreate((uri) => { + capUris.add(vscode.Uri.file(path.dirname(uri.fsPath)).toString()); + updateCapContext(); + decorator.refresh(); + }); + watcher.onDidDelete((uri) => { + capUris.delete(vscode.Uri.file(path.dirname(uri.fsPath)).toString()); + updateCapContext(); + decorator.refresh(); + }); + watcher.onDidChange(() => decorator.refresh()); + context.subscriptions.push(watcher); + + // Commands + const disposables = registerCapCommands(context, configProvider); + context.subscriptions.push(...disposables); +} diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts index ecdd81b3..53cdcfb1 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-commands.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -12,13 +12,13 @@ import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; import type {ContentFileSystemProvider} from './content-fs-provider.js'; import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js'; +import {openJobLog} from '../job-log-viewer.js'; async function showJobError(err: unknown, instance: B2CInstance, label: string): Promise { if (err instanceof JobExecutionError && err.execution.is_log_file_existing) { try { const log = await getJobLog(instance, err.execution); - const doc = await vscode.workspace.openTextDocument({content: log, language: 'log'}); - await vscode.window.showTextDocument(doc); + await openJobLog(err.execution.id ?? 'job', log); } catch { // Fall through to generic error } diff --git a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts index 16f9e1dd..facc960e 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts @@ -13,6 +13,7 @@ import JSZip from 'jszip'; import * as xml2js from 'xml2js'; import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; +import {openJobLog} from '../job-log-viewer.js'; export const CONTENT_SCHEME = 'b2c-content'; @@ -169,8 +170,7 @@ export class ContentFileSystemProvider implements vscode.FileSystemProvider { if (err instanceof JobExecutionError && err.execution.is_log_file_existing) { try { const log = await getJobLog(instance, err.execution); - const doc = await vscode.workspace.openTextDocument({content: log, language: 'log'}); - await vscode.window.showTextDocument(doc); + await openJobLog(err.execution.id ?? 'job', log); } catch { // Fall through to generic error } diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index e2f6b6bf..dc22c124 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -10,6 +10,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import {B2CExtensionConfig} from './config-provider.js'; +import {registerCap} from './cap/index.js'; +import {registerJobLogViewer} from './job-log-viewer.js'; import {registerContentTree} from './content-tree/index.js'; import {registerLogs} from './logs/index.js'; import {initializePlugins} from './plugins.js'; @@ -137,6 +139,8 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu // before the first resolveConfig() call. Failures are non-fatal. await initializePlugins(); + registerJobLogViewer(context); + const configProvider = new B2CExtensionConfig(log, context.workspaceState); context.subscriptions.push(configProvider); await configProvider.ensureResolved(); @@ -396,6 +400,9 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu if (settings.get('features.apiBrowser', true)) { registerApiBrowser(context, configProvider, log); } + if (settings.get('features.cap', true)) { + registerCap(context, configProvider); + } registerDebugger(context, configProvider); diff --git a/packages/b2c-vs-extension/src/job-log-viewer.ts b/packages/b2c-vs-extension/src/job-log-viewer.ts new file mode 100644 index 00000000..e66c8909 --- /dev/null +++ b/packages/b2c-vs-extension/src/job-log-viewer.ts @@ -0,0 +1,28 @@ +/* + * 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 * as vscode from 'vscode'; + +const JOB_LOG_SCHEME = 'b2c-job-log'; +const jobLogContents = new Map(); + +const jobLogProvider: vscode.TextDocumentContentProvider = { + provideTextDocumentContent(uri: vscode.Uri) { + return jobLogContents.get(uri.toString()) ?? ''; + }, +}; + +/** Register the job log content provider. Call once during extension activation. */ +export function registerJobLogViewer(context: vscode.ExtensionContext): void { + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(JOB_LOG_SCHEME, jobLogProvider)); +} + +/** Open a job log as a read-only virtual document (no save prompt on close). */ +export async function openJobLog(executionId: string, content: string): Promise { + const uri = vscode.Uri.parse(`${JOB_LOG_SCHEME}:${executionId}.log`); + jobLogContents.set(uri.toString(), content); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); +} diff --git a/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts b/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts index c9ea7c94..16bf2678 100644 --- a/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts +++ b/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts @@ -347,7 +347,7 @@ describe('create-lambda-adapter', () => { //express 4 style catch-all route, throws an error when installed // in express 5, see https://github.com/pillarjs/path-to-regexp#errors mockApp.get('/*', dummyCatchAllRoute); - } catch (error) { + } catch { //express 5 style catch-all route mockApp.get('/{*splat}', dummyCatchAllRoute); } diff --git a/skills/b2c-cli/skills/b2c-cap/SKILL.md b/skills/b2c-cli/skills/b2c-cap/SKILL.md new file mode 100644 index 00000000..1d07cf5a --- /dev/null +++ b/skills/b2c-cli/skills/b2c-cap/SKILL.md @@ -0,0 +1,115 @@ +--- +name: b2c-cap +description: Manage Commerce App Packages (CAPs) and commerce features using the b2c CLI. Use when validating, packaging, installing, uninstalling, listing, or pulling Commerce App Packages and commerce features on B2C Commerce instances, viewing configuration tasks, or pulling app sources for cartridge deployment or Storefront Next development. +--- + +# B2C CAP Skill + +Use the `b2c` CLI plugin to **validate, package, install, uninstall, list, and pull** Commerce App Packages (CAPs) and commerce features on Salesforce B2C Commerce instances. + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli cap list`). + +## Examples + +### List Installed Features + +```bash +# list all commerce features across all sites +b2c cap list + +# list features for specific sites +b2c cap list --site-id RefArch,SiteGenesis + +# list with full JSON output (includes config tasks and installation metadata) +b2c cap list --json + +# list locally detected CAP directories +b2c cap list --local +``` + +### Pull App Sources + +Pull installed Commerce App source packages for cartridge deployment or Storefront Next (`sfnext`) development. Pulled apps are extracted into `./commerce-apps/{name}/` and contain cartridges, IMPEX data, and `storefront-next/` extensions ready for use with the `sfnext` CLI. + +```bash +# pull all registry apps to ./commerce-apps +b2c cap pull + +# pull a specific app by name +b2c cap pull avalara-tax + +# pull to a custom output directory +b2c cap pull --output ./my-apps + +# pull apps installed on a specific site +b2c cap pull --site-id RefArch +``` + +### View Configuration Tasks + +```bash +# show configuration tasks with clickable BM links +b2c cap tasks avalara-tax --site-id RefArch + +# get tasks as JSON +b2c cap tasks avalara-tax --site-id RefArch --json +``` + +### Validate a CAP + +```bash +# validate a local CAP directory +b2c cap validate ./commerce-avalara-tax-app-v0.2.5 + +# validate a CAP zip file +b2c cap validate ./commerce-avalara-tax-app-v0.2.5.zip + +# validate with JSON output +b2c cap validate ./commerce-avalara-tax-app-v0.2.5 --json +``` + +### Package a CAP + +```bash +# package a CAP directory into a distributable zip +b2c cap package ./commerce-avalara-tax-app-v0.2.5 + +# package with a custom output path +b2c cap package ./commerce-avalara-tax-app-v0.2.5 --output ./dist/my-app.zip +``` + +### Install a CAP + +```bash +# install a CAP directory on an instance +b2c cap install ./commerce-avalara-tax-app-v0.2.5 --site-id RefArch + +# install a pre-packaged zip +b2c cap install ./commerce-avalara-tax-app-v0.2.5.zip --site-id RefArch + +# install with a timeout +b2c cap install ./commerce-avalara-tax-app-v0.2.5 --site-id RefArch --timeout 600 + +# skip validation before install +b2c cap install ./commerce-avalara-tax-app-v0.2.5 --site-id RefArch --skip-validate + +# remove the uploaded archive after install +b2c cap install ./commerce-avalara-tax-app-v0.2.5 --site-id RefArch --clean-archive +``` + +### Uninstall a CAP + +```bash +# uninstall a commerce app (domain is looked up automatically) +b2c cap uninstall avalara-tax --site-id RefArch +``` + +### More Commands + +See `b2c cap --help` for a full list of available commands and options in the `cap` topic. + +## Related Skills + +- `b2c-cli:b2c-job` - For running general jobs and site archive import/export +- `b2c-cli:b2c-site-import-export` - For site archive structure and metadata XML patterns +- `b2c-cli:b2c-code` - For deploying cartridges pulled from Commerce Apps