Skip to content

Commit b42df58

Browse files
authored
WebDAV Client (#14)
* webdav topic * refactoring http errors for webdav
1 parent e49cf90 commit b42df58

File tree

16 files changed

+926
-49
lines changed

16 files changed

+926
-49
lines changed

packages/b2c-cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@
7979
"job": {
8080
"description": "Run jobs and import/export site archives"
8181
},
82+
"webdav": {
83+
"description": "WebDAV file operations (ls, get, put, rm, zip, unzip)"
84+
},
8285
"mrt": {
8386
"description": "Manage Managed Runtime projects and deployments",
8487
"subtopics": {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import * as fs from 'node:fs';
7+
import {basename, resolve} from 'node:path';
8+
import {Args} from '@oclif/core';
9+
import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli';
10+
import {t} from '../../i18n/index.js';
11+
12+
interface GetResult {
13+
remotePath: string;
14+
localPath: string;
15+
size: number;
16+
}
17+
18+
export default class WebDavGet extends WebDavCommand<typeof WebDavGet> {
19+
static args = {
20+
remote: Args.string({
21+
description: 'Remote file path relative to root',
22+
required: true,
23+
}),
24+
local: Args.string({
25+
description: 'Local destination path (defaults to filename in current directory)',
26+
}),
27+
};
28+
29+
static description = t('commands.webdav.get.description', 'Download a file from WebDAV');
30+
31+
static enableJsonFlag = true;
32+
33+
static examples = [
34+
'<%= config.bin %> <%= command.id %> src/instance/export.zip',
35+
'<%= config.bin %> <%= command.id %> src/instance/export.zip ./downloads/export.zip',
36+
'<%= config.bin %> <%= command.id %> --root=logs customerror.log',
37+
];
38+
39+
async run(): Promise<GetResult> {
40+
this.ensureWebDavAuth();
41+
42+
const fullPath = this.buildPath(this.args.remote);
43+
44+
// Determine local path - default to filename in current directory
45+
const localPath = this.args.local || basename(this.args.remote);
46+
47+
this.log(t('commands.webdav.get.downloading', 'Downloading {{path}}...', {path: fullPath}));
48+
49+
const content = await this.instance.webdav.get(fullPath);
50+
51+
// Write to local file
52+
const buffer = Buffer.from(content);
53+
fs.writeFileSync(localPath, buffer);
54+
55+
const result: GetResult = {
56+
remotePath: fullPath,
57+
localPath: resolve(localPath),
58+
size: buffer.length,
59+
};
60+
61+
this.log(
62+
t('commands.webdav.get.success', 'Downloaded {{size}} bytes to {{path}}', {
63+
size: result.size,
64+
path: result.localPath,
65+
}),
66+
);
67+
68+
return result;
69+
}
70+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {Args} from '@oclif/core';
7+
import {WebDavCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli';
8+
import type {PropfindEntry} from '@salesforce/b2c-tooling-sdk/clients';
9+
import {t} from '../../i18n/index.js';
10+
11+
/**
12+
* Formats bytes into human-readable sizes.
13+
*/
14+
function formatBytes(bytes: number | undefined): string {
15+
if (bytes === undefined || bytes === null) return '-';
16+
if (bytes === 0) return '0 B';
17+
18+
const units = ['B', 'KB', 'MB', 'GB'];
19+
const k = 1024;
20+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1);
21+
const value = bytes / k ** i;
22+
23+
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
24+
}
25+
26+
/**
27+
* Extracts the display name from a PropfindEntry.
28+
*/
29+
function getDisplayName(entry: PropfindEntry): string {
30+
if (entry.displayName) {
31+
return entry.displayName;
32+
}
33+
// Extract filename from href
34+
const parts = entry.href.split('/').filter(Boolean);
35+
return parts.at(-1) || entry.href;
36+
}
37+
38+
const COLUMNS: Record<string, ColumnDef<PropfindEntry>> = {
39+
name: {
40+
header: 'Name',
41+
get: (e) => getDisplayName(e),
42+
},
43+
type: {
44+
header: 'Type',
45+
get: (e) => (e.isCollection ? 'dir' : 'file'),
46+
},
47+
size: {
48+
header: 'Size',
49+
get: (e) => formatBytes(e.contentLength),
50+
},
51+
modified: {
52+
header: 'Modified',
53+
get: (e) => (e.lastModified ? e.lastModified.toLocaleString() : '-'),
54+
extended: true,
55+
},
56+
contentType: {
57+
header: 'Content-Type',
58+
get: (e) => e.contentType || '-',
59+
extended: true,
60+
},
61+
};
62+
63+
const DEFAULT_COLUMNS = ['name', 'type', 'size'];
64+
65+
interface LsResult {
66+
path: string;
67+
count: number;
68+
entries: PropfindEntry[];
69+
}
70+
71+
export default class WebDavLs extends WebDavCommand<typeof WebDavLs> {
72+
static args = {
73+
path: Args.string({
74+
description: 'Path relative to root (defaults to root directory)',
75+
default: '',
76+
}),
77+
};
78+
79+
static description = t('commands.webdav.ls.description', 'List files and directories in a WebDAV location');
80+
81+
static enableJsonFlag = true;
82+
83+
static examples = [
84+
'<%= config.bin %> <%= command.id %>',
85+
'<%= config.bin %> <%= command.id %> src/instance',
86+
'<%= config.bin %> <%= command.id %> --root=cartridges',
87+
'<%= config.bin %> <%= command.id %> --root=logs --json',
88+
];
89+
90+
async run(): Promise<LsResult> {
91+
this.ensureWebDavAuth();
92+
93+
const fullPath = this.buildPath(this.args.path);
94+
95+
this.log(t('commands.webdav.ls.listing', 'Listing {{path}}...', {path: fullPath}));
96+
97+
const entries = await this.instance.webdav.propfind(fullPath, '1');
98+
99+
// Filter out the parent directory itself (first entry is usually the queried path)
100+
const filteredEntries = entries.filter((entry) => {
101+
const entryPath = decodeURIComponent(entry.href);
102+
const normalizedFullPath = fullPath.replace(/\/$/, '');
103+
return !entryPath.endsWith(`/${normalizedFullPath}`) && !entryPath.endsWith(`/${normalizedFullPath}/`);
104+
});
105+
106+
const result: LsResult = {
107+
path: fullPath,
108+
count: filteredEntries.length,
109+
entries: filteredEntries,
110+
};
111+
112+
if (this.jsonEnabled()) {
113+
return result;
114+
}
115+
116+
if (filteredEntries.length === 0) {
117+
this.log(t('commands.webdav.ls.empty', 'No files or directories found.'));
118+
return result;
119+
}
120+
121+
createTable(COLUMNS).render(filteredEntries, DEFAULT_COLUMNS);
122+
123+
return result;
124+
}
125+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {Args} from '@oclif/core';
7+
import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli';
8+
import {t} from '../../i18n/index.js';
9+
10+
interface MkdirResult {
11+
path: string;
12+
created: boolean;
13+
}
14+
15+
export default class WebDavMkdir extends WebDavCommand<typeof WebDavMkdir> {
16+
static args = {
17+
path: Args.string({
18+
description: 'Directory path to create (relative to root)',
19+
required: true,
20+
}),
21+
};
22+
23+
static description = t('commands.webdav.mkdir.description', 'Create a directory on WebDAV');
24+
25+
static enableJsonFlag = true;
26+
27+
static examples = [
28+
'<%= config.bin %> <%= command.id %> src/instance/my-folder',
29+
'<%= config.bin %> <%= command.id %> --root=temp my-temp-dir',
30+
'<%= config.bin %> <%= command.id %> --root=cartridges new-cartridge',
31+
];
32+
33+
async run(): Promise<MkdirResult> {
34+
this.ensureWebDavAuth();
35+
36+
const fullPath = this.buildPath(this.args.path);
37+
38+
// Create all parent directories and the target directory
39+
await this.createDirectoryPath(fullPath);
40+
41+
const result: MkdirResult = {
42+
path: fullPath,
43+
created: true,
44+
};
45+
46+
this.log(t('commands.webdav.mkdir.success', 'Created: {{path}}', {path: fullPath}));
47+
48+
return result;
49+
}
50+
51+
/**
52+
* Creates all directories in the path, similar to `mkdir -p`.
53+
*/
54+
private async createDirectoryPath(fullPath: string): Promise<void> {
55+
const parts = fullPath.split('/').filter(Boolean);
56+
57+
let currentPath = '';
58+
for (const part of parts) {
59+
currentPath = currentPath ? `${currentPath}/${part}` : part;
60+
// eslint-disable-next-line no-await-in-loop
61+
await this.instance.webdav.mkcol(currentPath);
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)