diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 41e572ee..5ae7dc31 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -79,6 +79,9 @@ "job": { "description": "Run jobs and import/export site archives" }, + "webdav": { + "description": "WebDAV file operations (ls, get, put, rm, zip, unzip)" + }, "mrt": { "description": "Manage Managed Runtime projects and deployments", "subtopics": { diff --git a/packages/b2c-cli/src/commands/webdav/get.ts b/packages/b2c-cli/src/commands/webdav/get.ts new file mode 100644 index 00000000..114ed932 --- /dev/null +++ b/packages/b2c-cli/src/commands/webdav/get.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 fs from 'node:fs'; +import {basename, resolve} from 'node:path'; +import {Args} from '@oclif/core'; +import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../i18n/index.js'; + +interface GetResult { + remotePath: string; + localPath: string; + size: number; +} + +export default class WebDavGet extends WebDavCommand { + static args = { + remote: Args.string({ + description: 'Remote file path relative to root', + required: true, + }), + local: Args.string({ + description: 'Local destination path (defaults to filename in current directory)', + }), + }; + + static description = t('commands.webdav.get.description', 'Download a file from WebDAV'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> src/instance/export.zip', + '<%= config.bin %> <%= command.id %> src/instance/export.zip ./downloads/export.zip', + '<%= config.bin %> <%= command.id %> --root=logs customerror.log', + ]; + + async run(): Promise { + this.ensureWebDavAuth(); + + const fullPath = this.buildPath(this.args.remote); + + // Determine local path - default to filename in current directory + const localPath = this.args.local || basename(this.args.remote); + + this.log(t('commands.webdav.get.downloading', 'Downloading {{path}}...', {path: fullPath})); + + const content = await this.instance.webdav.get(fullPath); + + // Write to local file + const buffer = Buffer.from(content); + fs.writeFileSync(localPath, buffer); + + const result: GetResult = { + remotePath: fullPath, + localPath: resolve(localPath), + size: buffer.length, + }; + + this.log( + t('commands.webdav.get.success', 'Downloaded {{size}} bytes to {{path}}', { + size: result.size, + path: result.localPath, + }), + ); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/webdav/ls.ts b/packages/b2c-cli/src/commands/webdav/ls.ts new file mode 100644 index 00000000..05ace7e5 --- /dev/null +++ b/packages/b2c-cli/src/commands/webdav/ls.ts @@ -0,0 +1,125 @@ +/* + * 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 {WebDavCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import type {PropfindEntry} from '@salesforce/b2c-tooling-sdk/clients'; +import {t} from '../../i18n/index.js'; + +/** + * Formats bytes into human-readable sizes. + */ +function formatBytes(bytes: number | undefined): string { + if (bytes === undefined || bytes === null) return '-'; + if (bytes === 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB']; + const k = 1024; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1); + const value = bytes / k ** i; + + return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +/** + * Extracts the display name from a PropfindEntry. + */ +function getDisplayName(entry: PropfindEntry): string { + if (entry.displayName) { + return entry.displayName; + } + // Extract filename from href + const parts = entry.href.split('/').filter(Boolean); + return parts.at(-1) || entry.href; +} + +const COLUMNS: Record> = { + name: { + header: 'Name', + get: (e) => getDisplayName(e), + }, + type: { + header: 'Type', + get: (e) => (e.isCollection ? 'dir' : 'file'), + }, + size: { + header: 'Size', + get: (e) => formatBytes(e.contentLength), + }, + modified: { + header: 'Modified', + get: (e) => (e.lastModified ? e.lastModified.toLocaleString() : '-'), + extended: true, + }, + contentType: { + header: 'Content-Type', + get: (e) => e.contentType || '-', + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['name', 'type', 'size']; + +interface LsResult { + path: string; + count: number; + entries: PropfindEntry[]; +} + +export default class WebDavLs extends WebDavCommand { + static args = { + path: Args.string({ + description: 'Path relative to root (defaults to root directory)', + default: '', + }), + }; + + static description = t('commands.webdav.ls.description', 'List files and directories in a WebDAV location'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> src/instance', + '<%= config.bin %> <%= command.id %> --root=cartridges', + '<%= config.bin %> <%= command.id %> --root=logs --json', + ]; + + async run(): Promise { + this.ensureWebDavAuth(); + + const fullPath = this.buildPath(this.args.path); + + this.log(t('commands.webdav.ls.listing', 'Listing {{path}}...', {path: fullPath})); + + const entries = await this.instance.webdav.propfind(fullPath, '1'); + + // Filter out the parent directory itself (first entry is usually the queried path) + const filteredEntries = entries.filter((entry) => { + const entryPath = decodeURIComponent(entry.href); + const normalizedFullPath = fullPath.replace(/\/$/, ''); + return !entryPath.endsWith(`/${normalizedFullPath}`) && !entryPath.endsWith(`/${normalizedFullPath}/`); + }); + + const result: LsResult = { + path: fullPath, + count: filteredEntries.length, + entries: filteredEntries, + }; + + if (this.jsonEnabled()) { + return result; + } + + if (filteredEntries.length === 0) { + this.log(t('commands.webdav.ls.empty', 'No files or directories found.')); + return result; + } + + createTable(COLUMNS).render(filteredEntries, DEFAULT_COLUMNS); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/webdav/mkdir.ts b/packages/b2c-cli/src/commands/webdav/mkdir.ts new file mode 100644 index 00000000..e824d850 --- /dev/null +++ b/packages/b2c-cli/src/commands/webdav/mkdir.ts @@ -0,0 +1,64 @@ +/* + * 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 {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../i18n/index.js'; + +interface MkdirResult { + path: string; + created: boolean; +} + +export default class WebDavMkdir extends WebDavCommand { + static args = { + path: Args.string({ + description: 'Directory path to create (relative to root)', + required: true, + }), + }; + + static description = t('commands.webdav.mkdir.description', 'Create a directory on WebDAV'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> src/instance/my-folder', + '<%= config.bin %> <%= command.id %> --root=temp my-temp-dir', + '<%= config.bin %> <%= command.id %> --root=cartridges new-cartridge', + ]; + + async run(): Promise { + this.ensureWebDavAuth(); + + const fullPath = this.buildPath(this.args.path); + + // Create all parent directories and the target directory + await this.createDirectoryPath(fullPath); + + const result: MkdirResult = { + path: fullPath, + created: true, + }; + + this.log(t('commands.webdav.mkdir.success', 'Created: {{path}}', {path: fullPath})); + + return result; + } + + /** + * Creates all directories in the path, similar to `mkdir -p`. + */ + private async createDirectoryPath(fullPath: string): Promise { + const parts = fullPath.split('/').filter(Boolean); + + let currentPath = ''; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : part; + // eslint-disable-next-line no-await-in-loop + await this.instance.webdav.mkcol(currentPath); + } + } +} diff --git a/packages/b2c-cli/src/commands/webdav/put.ts b/packages/b2c-cli/src/commands/webdav/put.ts new file mode 100644 index 00000000..488c6ef1 --- /dev/null +++ b/packages/b2c-cli/src/commands/webdav/put.ts @@ -0,0 +1,136 @@ +/* + * 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 {basename, extname, resolve} from 'node:path'; +import {Args} from '@oclif/core'; +import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../i18n/index.js'; + +/** + * Common content type mappings by file extension. + */ +const CONTENT_TYPES: Record = { + '.zip': 'application/zip', + '.xml': 'application/xml', + '.json': 'application/json', + '.txt': 'text/plain', + '.csv': 'text/csv', + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', +}; + +/** + * Gets the content type for a file based on its extension. + */ +function getContentType(filePath: string): string | undefined { + const ext = extname(filePath).toLowerCase(); + return CONTENT_TYPES[ext]; +} + +interface PutResult { + localPath: string; + remotePath: string; + size: number; + contentType?: string; +} + +export default class WebDavPut extends WebDavCommand { + static args = { + local: Args.string({ + description: 'Local file path to upload', + required: true, + }), + remote: Args.string({ + description: 'Remote destination (directory or file path). If ending with / or is /, uses source filename.', + required: true, + }), + }; + + static description = t('commands.webdav.put.description', 'Upload a file to WebDAV'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ./export.zip / # uploads to root as export.zip', + '<%= config.bin %> <%= command.id %> ./export.zip src/instance/ # uploads to src/instance/export.zip', + '<%= config.bin %> <%= command.id %> ./data.xml src/instance/renamed.xml # uploads with new name', + '<%= config.bin %> <%= command.id %> ./file.tar.gz / --root=temp # uploads to Temp/file.tar.gz', + ]; + + async run(): Promise { + this.ensureWebDavAuth(); + + const localPath = resolve(this.args.local); + const localFilename = basename(localPath); + + // Verify local file exists + if (!fs.existsSync(localPath)) { + this.error(t('commands.webdav.put.fileNotFound', 'Local file not found: {{path}}', {path: localPath})); + } + + // Determine remote path - if it looks like a directory, append the source filename + let remotePath = this.args.remote; + if (remotePath === '/' || remotePath === '' || remotePath.endsWith('/')) { + // Treat as directory, append source filename + remotePath = remotePath === '/' || remotePath === '' ? localFilename : `${remotePath}${localFilename}`; + } + const fullPath = this.buildPath(remotePath); + + // Read local file + const content = fs.readFileSync(localPath); + const contentType = getContentType(localPath); + + this.log( + t('commands.webdav.put.uploading', 'Uploading {{local}} to {{remote}}...', {local: localPath, remote: fullPath}), + ); + + // Create parent directories if needed + await this.ensureParentDirectories(fullPath); + + // Upload the file + await this.instance.webdav.put(fullPath, content, contentType); + + const result: PutResult = { + localPath, + remotePath: fullPath, + size: content.length, + contentType, + }; + + this.log( + t('commands.webdav.put.success', 'Uploaded {{size}} bytes to {{path}}', { + size: result.size, + path: result.remotePath, + }), + ); + + return result; + } + + /** + * Ensures all parent directories exist for the given path. + * Note: Sequential await is required here as each directory depends on its parent existing. + */ + private async ensureParentDirectories(fullPath: string): Promise { + const parts = fullPath.split('/').filter(Boolean); + // Remove the filename, keep only directory parts + parts.pop(); + + let currentPath = ''; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}/${part}` : part; + // eslint-disable-next-line no-await-in-loop + await this.instance.webdav.mkcol(currentPath); + } + } +} diff --git a/packages/b2c-cli/src/commands/webdav/rm.ts b/packages/b2c-cli/src/commands/webdav/rm.ts new file mode 100644 index 00000000..0709ce72 --- /dev/null +++ b/packages/b2c-cli/src/commands/webdav/rm.ts @@ -0,0 +1,88 @@ +/* + * 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 readline from 'node:readline'; +import {Args, Flags} from '@oclif/core'; +import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../i18n/index.js'; + +/** + * Simple confirmation prompt. + */ +async function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + + return new Promise((resolve) => { + rl.question(`${message} `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +interface RmResult { + path: string; + deleted: boolean; +} + +export default class WebDavRm extends WebDavCommand { + static args = { + path: Args.string({ + description: 'Path to delete relative to root', + required: true, + }), + }; + + static description = t('commands.webdav.rm.description', 'Delete a file or directory from WebDAV'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> src/instance/old-export.zip', + '<%= config.bin %> <%= command.id %> src/instance/old-export.zip --force', + '<%= config.bin %> <%= command.id %> --root=temp my-temp-dir --force', + ]; + + static flags = { + ...WebDavCommand.baseFlags, + force: Flags.boolean({ + char: 'f', + description: 'Skip confirmation prompt', + default: false, + }), + }; + + async run(): Promise { + this.ensureWebDavAuth(); + + const fullPath = this.buildPath(this.args.path); + + // Confirm deletion unless --force is used + if (!this.flags.force) { + const confirmed = await confirm( + t('commands.webdav.rm.confirm', 'Are you sure you want to delete "{{path}}"? (y/n)', {path: fullPath}), + ); + + if (!confirmed) { + this.log(t('commands.webdav.rm.cancelled', 'Deletion cancelled')); + return {path: fullPath, deleted: false}; + } + } + + await this.instance.webdav.delete(fullPath); + + const result: RmResult = { + path: fullPath, + deleted: true, + }; + + this.log(t('commands.webdav.rm.success', 'Deleted: {{path}}', {path: fullPath})); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/webdav/unzip.ts b/packages/b2c-cli/src/commands/webdav/unzip.ts new file mode 100644 index 00000000..06b7e9ab --- /dev/null +++ b/packages/b2c-cli/src/commands/webdav/unzip.ts @@ -0,0 +1,68 @@ +/* + * 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 {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../i18n/index.js'; + +const UNZIP_BODY = new URLSearchParams({method: 'UNZIP'}).toString(); + +interface UnzipResult { + archivePath: string; + extractPath: string; +} + +export default class WebDavUnzip extends WebDavCommand { + static args = { + path: Args.string({ + description: 'Remote zip file path (relative to root)', + required: true, + }), + }; + + static description = t('commands.webdav.unzip.description', 'Extract a remote zip archive'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> src/instance/export.zip', + '<%= config.bin %> <%= command.id %> --root=cartridges my-cartridge.zip', + ]; + + async run(): Promise { + this.ensureWebDavAuth(); + + const fullPath = this.buildPath(this.args.path); + + // Determine the extract directory (same location without .zip extension) + const extractPath = fullPath.endsWith('.zip') ? fullPath.slice(0, -4) : fullPath; + + this.log(t('commands.webdav.unzip.extracting', 'Extracting {{path}}...', {path: fullPath})); + + const response = await this.instance.webdav.request(fullPath, { + method: 'POST', + body: UNZIP_BODY, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (!response.ok) { + const text = await response.text(); + this.error( + t('commands.webdav.unzip.failed', 'UNZIP failed: {{status}} - {{text}}', {status: response.status, text}), + ); + } + + const result: UnzipResult = { + archivePath: fullPath, + extractPath, + }; + + this.log(t('commands.webdav.unzip.success', 'Extracted to: {{path}}', {path: extractPath})); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/webdav/zip.ts b/packages/b2c-cli/src/commands/webdav/zip.ts new file mode 100644 index 00000000..67db4949 --- /dev/null +++ b/packages/b2c-cli/src/commands/webdav/zip.ts @@ -0,0 +1,64 @@ +/* + * 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 {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../i18n/index.js'; + +const ZIP_BODY = new URLSearchParams({method: 'ZIP'}).toString(); + +interface ZipResult { + sourcePath: string; + archivePath: string; +} + +export default class WebDavZip extends WebDavCommand { + static args = { + path: Args.string({ + description: 'Remote path to zip (relative to root)', + required: true, + }), + }; + + static description = t('commands.webdav.zip.description', 'Create a zip archive of a remote file or directory'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> src/instance/data', + '<%= config.bin %> <%= command.id %> --root=cartridges my-cartridge', + ]; + + async run(): Promise { + this.ensureWebDavAuth(); + + const fullPath = this.buildPath(this.args.path); + const archivePath = `${fullPath}.zip`; + + this.log(t('commands.webdav.zip.zipping', 'Zipping {{path}}...', {path: fullPath})); + + const response = await this.instance.webdav.request(fullPath, { + method: 'POST', + body: ZIP_BODY, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (!response.ok) { + const text = await response.text(); + this.error(t('commands.webdav.zip.failed', 'ZIP failed: {{status}} - {{text}}', {status: response.status, text})); + } + + const result: ZipResult = { + sourcePath: fullPath, + archivePath, + }; + + this.log(t('commands.webdav.zip.success', 'Created archive: {{path}}', {path: archivePath})); + + return result; + } +} diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 06fc9259..3c647f81 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -112,6 +112,17 @@ "default": "./dist/cjs/cli/index.js" } }, + "./clients": { + "development": "./src/clients/index.ts", + "import": { + "types": "./dist/esm/clients/index.d.ts", + "default": "./dist/esm/clients/index.js" + }, + "require": { + "types": "./dist/cjs/clients/index.d.ts", + "default": "./dist/cjs/clients/index.js" + } + }, "./logging": { "development": "./src/logging/index.ts", "import": { @@ -122,6 +133,17 @@ "types": "./dist/cjs/logging/index.d.ts", "default": "./dist/cjs/logging/index.js" } + }, + "./errors": { + "development": "./src/errors/index.ts", + "import": { + "types": "./dist/esm/errors/index.d.ts", + "default": "./dist/esm/errors/index.js" + }, + "require": { + "types": "./dist/cjs/errors/index.d.ts", + "default": "./dist/cjs/errors/index.js" + } } }, "main": "./dist/cjs/index.js", @@ -151,6 +173,7 @@ "@tony.ganchev/eslint-plugin-header": "^3.1.11", "@types/archiver": "^7.0.0", "@types/node": "^18.19.130", + "@types/xml2js": "^0.4.14", "eslint": "^9", "eslint-config-prettier": "^10", "eslint-plugin-prettier": "^5.5.4", @@ -182,6 +205,7 @@ "open": "^11.0.0", "openapi-fetch": "^0.15.0", "pino": "^10.1.0", - "pino-pretty": "^13.1.2" + "pino-pretty": "^13.1.2", + "xml2js": "^0.6.2" } } diff --git a/packages/b2c-tooling-sdk/src/auth/types.ts b/packages/b2c-tooling-sdk/src/auth/types.ts index 4284a1fc..eb17fb02 100644 --- a/packages/b2c-tooling-sdk/src/auth/types.ts +++ b/packages/b2c-tooling-sdk/src/auth/types.ts @@ -5,7 +5,7 @@ */ export interface AuthStrategy { /** - * Performs a fetch request. + * Performs a fetch request with authentication. * Implementations MUST handle header injection and 401 retries (token refresh) internally. */ fetch(url: string, init?: RequestInit): Promise; diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index d6265306..c3debd27 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -18,6 +18,7 @@ * - {@link InstanceCommand} - Adds B2C Commerce instance configuration * - {@link CartridgeCommand} - Adds cartridge path configuration for code operations * - {@link JobCommand} - Adds job execution configuration + * - {@link WebDavCommand} - Adds WebDAV root directory configuration for file operations * - {@link MrtCommand} - Adds Managed Runtime API authentication * - {@link OdsCommand} - Adds On-Demand Sandbox configuration * @@ -30,7 +31,8 @@ * └─ OAuthCommand (adds OAuth) * └─ InstanceCommand (adds instance config) * ├─ CartridgeCommand (adds cartridge paths) - * └─ JobCommand (adds job config) + * ├─ JobCommand (adds job config) + * └─ WebDavCommand (adds WebDAV root config) * └─ MrtCommand (adds MRT API auth) * └─ OdsCommand (adds ODS config) * ``` @@ -97,6 +99,8 @@ export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; +export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; +export type {WebDavRootKey} from './webdav-command.js'; // Config utilities export {loadConfig, findDwJson} from './config.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/webdav-command.ts b/packages/b2c-tooling-sdk/src/cli/webdav-command.ts new file mode 100644 index 00000000..409e13fe --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/webdav-command.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 {Command, Flags} from '@oclif/core'; +import {InstanceCommand} from './instance-command.js'; +import {t} from '../i18n/index.js'; + +/** + * WebDAV root location identifiers. + * + * These map to the standard B2C Commerce WebDAV directories. + */ +export const WEBDAV_ROOTS = { + IMPEX: 'Impex', + TEMP: 'Temp', + CARTRIDGES: 'Cartridges', + REALMDATA: 'Realmdata', + CATALOGS: 'Catalogs', + LIBRARIES: 'Libraries', + STATIC: 'Static', + LOGS: 'Logs', + SECURITYLOGS: 'Securitylogs', +} as const; + +/** + * Type for valid WebDAV root keys. + */ +export type WebDavRootKey = keyof typeof WEBDAV_ROOTS; + +/** + * Array of valid root location values for flag validation. + */ +export const VALID_ROOTS = Object.keys(WEBDAV_ROOTS) as WebDavRootKey[]; + +/** + * Base command for WebDAV file operations. + * + * Extends InstanceCommand with a `--root` flag to specify the WebDAV + * directory root for operations. Provides helper methods for building + * paths relative to the selected root. + * + * @example + * ```typescript + * export default class MyWebDavCommand extends WebDavCommand { + * static args = { + * path: Args.string({ required: true, description: 'Remote path' }), + * }; + * + * async run(): Promise { + * const fullPath = this.buildPath(this.args.path); + * // fullPath = "Impex/src/data/file.xml" when --root=impex path=src/data/file.xml + * const entries = await this.instance.webdav.propfind(fullPath); + * } + * } + * ``` + */ +export abstract class WebDavCommand extends InstanceCommand { + static baseFlags = { + ...InstanceCommand.baseFlags, + root: Flags.string({ + char: 'r', + description: 'WebDAV root directory', + default: 'IMPEX', + helpGroup: 'WEBDAV', + options: VALID_ROOTS.map((r) => r.toLowerCase()), + }), + }; + + /** + * Builds a full WebDAV path from the root and a relative path. + * + * @param relativePath - Path relative to the root directory + * @returns Full WebDAV path including the root prefix + * + * @example + * ```typescript + * // With --root=impex + * this.buildPath('src/data/file.xml') // Returns: "Impex/src/data/file.xml" + * this.buildPath('/src/data/file.xml') // Returns: "Impex/src/data/file.xml" + * this.buildPath('') // Returns: "Impex" + * ``` + */ + protected buildPath(relativePath: string): string { + const rootKey = this.flags.root.toUpperCase() as WebDavRootKey; + const rootPath = WEBDAV_ROOTS[rootKey]; + + if (!rootPath) { + this.error( + t( + 'error.invalidWebdavRoot', + `Invalid WebDAV root: ${this.flags.root}. Valid options: ${VALID_ROOTS.join(', ')}`, + ), + ); + } + + if (!relativePath || relativePath === '' || relativePath === '/') { + return rootPath; + } + + const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; + return `${rootPath}/${cleanPath}`; + } + + /** + * Gets the current root path. + * + * @returns The WebDAV root path (e.g., "Impex") + */ + protected get rootPath(): string { + const rootKey = this.flags.root.toUpperCase() as WebDavRootKey; + return WEBDAV_ROOTS[rootKey]; + } + + /** + * Validates that WebDAV credentials are available before operations. + * Called by subclasses that need to ensure auth is configured. + */ + protected ensureWebDavAuth(): void { + this.requireServer(); + this.requireWebDavCredentials(); + } +} diff --git a/packages/b2c-tooling-sdk/src/clients/webdav.ts b/packages/b2c-tooling-sdk/src/clients/webdav.ts index ced4388c..0d3df36b 100644 --- a/packages/b2c-tooling-sdk/src/clients/webdav.ts +++ b/packages/b2c-tooling-sdk/src/clients/webdav.ts @@ -11,7 +11,9 @@ * * @module clients/webdav */ +import {parseStringPromise} from 'xml2js'; import type {AuthStrategy} from '../auth/types.js'; +import {HTTPError} from '../errors/http-error.js'; import {getLogger} from '../logging/logger.js'; /** @@ -65,8 +67,11 @@ export class WebDavClient { /** * Builds the full URL for a WebDAV path. + * + * @param path - Path relative to /webdav/Sites/ + * @returns Full URL */ - private buildUrl(path: string): string { + buildUrl(path: string): string { const cleanPath = path.startsWith('/') ? path.slice(1) : path; return `${this.baseUrl}/${cleanPath}`; } @@ -166,8 +171,7 @@ export class WebDavClient { // 201 = created, 405 = already exists (acceptable) if (!response.ok && response.status !== 405) { - const text = await response.text(); - throw new Error(`MKCOL failed: ${response.status} ${response.statusText} - ${text}`); + throw new HTTPError(`MKCOL failed: ${response.status} ${response.statusText}`, response, 'MKCOL'); } } @@ -187,15 +191,10 @@ export class WebDavClient { headers['Content-Type'] = contentType; } - const response = await this.request(path, { - method: 'PUT', - headers, - body: content, - }); + const response = await this.request(path, {method: 'PUT', headers, body: content}); if (!response.ok) { - const text = await response.text(); - throw new Error(`PUT failed: ${response.status} ${response.statusText} - ${text}`); + throw new HTTPError(`PUT failed: ${response.status} ${response.statusText}`, response, 'PUT'); } } @@ -212,7 +211,7 @@ export class WebDavClient { const response = await this.request(path, {method: 'GET'}); if (!response.ok) { - throw new Error(`GET failed: ${response.status} ${response.statusText}`); + throw new HTTPError(`GET failed: ${response.status} ${response.statusText}`, response, 'GET'); } return response.arrayBuffer(); @@ -222,6 +221,7 @@ export class WebDavClient { * Deletes a file or directory. * * @param path - Path to delete + * @throws Error if the path doesn't exist (404) or deletion fails * * @example * await client.delete('Cartridges/v1/old-cartridge'); @@ -229,10 +229,8 @@ export class WebDavClient { async delete(path: string): Promise { const response = await this.request(path, {method: 'DELETE'}); - // 404 is acceptable (already deleted) - if (!response.ok && response.status !== 404) { - const text = await response.text(); - throw new Error(`DELETE failed: ${response.status} ${response.statusText} - ${text}`); + if (!response.ok) { + throw new HTTPError(`DELETE failed: ${response.status} ${response.statusText}`, response, 'DELETE'); } } @@ -269,12 +267,11 @@ export class WebDavClient { }); if (!response.ok) { - const text = await response.text(); - throw new Error(`PROPFIND failed: ${response.status} ${response.statusText} - ${text}`); + throw new HTTPError(`PROPFIND failed: ${response.status} ${response.statusText}`, response, 'PROPFIND'); } const xml = await response.text(); - return this.parsePropfindResponse(xml); + return await this.parsePropfindResponse(xml); } /** @@ -289,25 +286,40 @@ export class WebDavClient { } /** - * Parses PROPFIND XML response into structured entries. + * Parses PROPFIND XML response into structured entries using xml2js. */ - private parsePropfindResponse(xml: string): PropfindEntry[] { + private async parsePropfindResponse(xml: string): Promise { const entries: PropfindEntry[] = []; - // Simple regex-based parsing for WebDAV response - // Note: For production, consider using a proper XML parser - const responsePattern = /([\s\S]*?)<\/D:response>/gi; - let match; + // Parse with xml2js, stripping namespace prefixes for easier access + const result = await parseStringPromise(xml, { + tagNameProcessors: [(name: string) => name.replace(/^[^:]+:/, '')], // Strip namespace prefix + explicitArray: false, + }); + + // Get the multistatus root - may be 'multistatus' or 'D:multistatus' after processing + const multistatus = result.multistatus; + if (!multistatus) { + return entries; + } + + // Get response array - may be single object or array + const responses = Array.isArray(multistatus.response) ? multistatus.response : [multistatus.response]; - while ((match = responsePattern.exec(xml)) !== null) { - const responseXml = match[1]; + for (const response of responses) { + if (!response) continue; - const href = this.extractXmlValue(responseXml, 'D:href') || ''; - const displayName = this.extractXmlValue(responseXml, 'D:displayname'); - const isCollection = responseXml.includes(']*>([^<]*)`, 'i'); - const match = pattern.exec(xml); - return match ? match[1] : undefined; + private getXmlText(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return value || undefined; + if (typeof value === 'object' && '_' in (value as Record)) { + return (value as Record)._ || undefined; + } + return undefined; } } diff --git a/packages/b2c-tooling-sdk/src/errors/http-error.ts b/packages/b2c-tooling-sdk/src/errors/http-error.ts new file mode 100644 index 00000000..8310fc2f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/errors/http-error.ts @@ -0,0 +1,47 @@ +/* + * 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 + */ +/** + * Custom error class for HTTP errors from API clients. + * + * Wraps the Response object directly for full access to response details. + * + * @example + * try { + * await webdav.delete('some/path'); + * } catch (error) { + * if (error instanceof HTTPError && error.response.status === 404) { + * // Handle not found + * } + * throw error; + * } + * + * @module errors/http-error + */ + +/** + * Error thrown when an HTTP request fails. + * + * Wraps the original Response for access to status, headers, and body. + */ +export class HTTPError extends Error { + /** + * The original Response object from the failed request. + * Note: Body can only be read once via response.text(), response.json(), etc. + */ + readonly response: Response; + + /** + * HTTP method used for the request (GET, POST, PUT, DELETE, etc.). + */ + readonly method: string; + + constructor(message: string, response: Response, method: string) { + super(message); + this.name = 'HTTPError'; + this.response = response; + this.method = method; + } +} diff --git a/packages/b2c-tooling-sdk/src/errors/index.ts b/packages/b2c-tooling-sdk/src/errors/index.ts new file mode 100644 index 00000000..d1bd570e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/errors/index.ts @@ -0,0 +1,11 @@ +/* + * 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 + */ +/** + * Error types for B2C tooling operations. + * + * @module errors + */ +export {HTTPError} from './http-error.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcd2bb35..52fa36dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: pino-pretty: specifier: ^13.1.2 version: 13.1.2 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 devDependencies: '@eslint/compat': specifier: ^1 @@ -163,6 +166,9 @@ importers: '@types/node': specifier: ^18.19.130 version: 18.19.130 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 eslint: specifier: ^9 version: 9.39.1 @@ -1609,6 +1615,9 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@typescript-eslint/eslint-plugin@8.46.4': resolution: {integrity: sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3946,6 +3955,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.4.3: + resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} + search-insights@2.17.3: resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} @@ -4473,6 +4485,14 @@ packages: resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==} engines: {node: '>=20'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6366,6 +6386,10 @@ snapshots: '@types/wrap-ansi@3.0.0': {} + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 18.19.130 + '@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7328,8 +7352,8 @@ snapshots: eslint-config-oclif: 5.2.2(eslint@9.39.1) eslint-config-xo: 0.49.0(eslint@9.39.1) eslint-config-xo-space: 0.35.0(eslint@9.39.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1) eslint-plugin-jsdoc: 50.8.0(eslint@9.39.1) eslint-plugin-mocha: 10.5.0(eslint@9.39.1) eslint-plugin-n: 17.23.1(eslint@9.39.1)(typescript@5.9.3) @@ -7374,7 +7398,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@10.2.2) @@ -7385,18 +7409,18 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.46.4(eslint@9.39.1)(typescript@5.9.3) eslint: 9.39.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1) transitivePeerDependencies: - supports-color @@ -7413,7 +7437,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7424,7 +7448,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -8930,6 +8954,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.4.3: {} + search-insights@2.17.3: {} secure-json-parse@4.1.0: {} @@ -9592,6 +9618,13 @@ snapshots: is-wsl: 3.1.0 powershell-utils: 0.1.0 + xml2js@0.6.2: + dependencies: + sax: 1.4.3 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + y18n@5.0.8: {} yaml-ast-parser@0.0.43: {}