Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/b2c-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
70 changes: 70 additions & 0 deletions packages/b2c-cli/src/commands/webdav/get.ts
Original file line number Diff line number Diff line change
@@ -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<typeof WebDavGet> {
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<GetResult> {
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;
}
}
125 changes: 125 additions & 0 deletions packages/b2c-cli/src/commands/webdav/ls.ts
Original file line number Diff line number Diff line change
@@ -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<string, ColumnDef<PropfindEntry>> = {
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<typeof WebDavLs> {
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<LsResult> {
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;
}
}
64 changes: 64 additions & 0 deletions packages/b2c-cli/src/commands/webdav/mkdir.ts
Original file line number Diff line number Diff line change
@@ -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<typeof WebDavMkdir> {
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<MkdirResult> {
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<void> {
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);
}
}
}
Loading