scp-next 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -26
- package/dist/cli/index.js +228 -38
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +233 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +234 -38
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/client/client.ts
|
|
2
|
-
import { access as access4, mkdir as mkdir2 } from "fs/promises";
|
|
2
|
+
import { access as access4, mkdir as mkdir2, stat as stat3 } from "fs/promises";
|
|
3
3
|
import path4 from "path";
|
|
4
4
|
|
|
5
5
|
// src/client/transport.ts
|
|
@@ -39,6 +39,12 @@ function redactSensitiveValues(value) {
|
|
|
39
39
|
}
|
|
40
40
|
return value;
|
|
41
41
|
}
|
|
42
|
+
function formatErrorMessage(error) {
|
|
43
|
+
if (error instanceof Error) {
|
|
44
|
+
return String(redactSensitiveValues(error.message));
|
|
45
|
+
}
|
|
46
|
+
return String(redactSensitiveValues(error));
|
|
47
|
+
}
|
|
42
48
|
|
|
43
49
|
// src/errors/index.ts
|
|
44
50
|
var ScpNextError = class extends Error {
|
|
@@ -95,7 +101,7 @@ function toScpNextError(error) {
|
|
|
95
101
|
if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
|
|
96
102
|
return new AuthenticationError("SSH authentication failed.", { cause: error });
|
|
97
103
|
}
|
|
98
|
-
if (lowerMessage.includes("host verification") || lowerMessage.includes("host key")) {
|
|
104
|
+
if (lowerMessage.includes("host verification") || lowerMessage.includes("host key") || lowerMessage.includes("fingerprint") || lowerMessage.includes("host") && lowerMessage.includes("denied")) {
|
|
99
105
|
return new HostVerificationError("SSH host verification failed.", { cause: error });
|
|
100
106
|
}
|
|
101
107
|
if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
|
|
@@ -169,8 +175,39 @@ async function getLocalPathKind(inputPath) {
|
|
|
169
175
|
function normalizeFingerprint(fingerprint) {
|
|
170
176
|
return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
|
|
171
177
|
}
|
|
178
|
+
function isHexFingerprint(fingerprint) {
|
|
179
|
+
return /^[0-9a-f]+$/i.test(fingerprint);
|
|
180
|
+
}
|
|
181
|
+
function fingerprintCandidates(fingerprint) {
|
|
182
|
+
const normalized = normalizeFingerprint(fingerprint);
|
|
183
|
+
const candidates = /* @__PURE__ */ new Set([normalized]);
|
|
184
|
+
if (isHexFingerprint(normalized)) {
|
|
185
|
+
candidates.add(normalized.toLowerCase());
|
|
186
|
+
} else {
|
|
187
|
+
try {
|
|
188
|
+
candidates.add(Buffer.from(normalized, "base64").toString("hex"));
|
|
189
|
+
} catch {
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return [...candidates];
|
|
193
|
+
}
|
|
172
194
|
function isFingerprintMatch(actual, expected) {
|
|
173
|
-
|
|
195
|
+
const normalizedActual = normalizeFingerprint(actual);
|
|
196
|
+
const normalizedExpected = normalizeFingerprint(expected);
|
|
197
|
+
if (normalizedActual === normalizedExpected) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return isHexFingerprint(normalizedActual) && isHexFingerprint(normalizedExpected) && normalizedActual.toLowerCase() === normalizedExpected.toLowerCase();
|
|
201
|
+
}
|
|
202
|
+
function hostVerificationHint(host) {
|
|
203
|
+
return [
|
|
204
|
+
"",
|
|
205
|
+
"Typical fix:",
|
|
206
|
+
` ssh ${host}`,
|
|
207
|
+
"",
|
|
208
|
+
"If you trust the host key, accept it and retry the scp-next command.",
|
|
209
|
+
"Alternatively configure hostFingerprint or knownHostsFile explicitly."
|
|
210
|
+
].join("\n");
|
|
174
211
|
}
|
|
175
212
|
async function resolveAllowedFingerprints(options) {
|
|
176
213
|
const fingerprints = /* @__PURE__ */ new Set();
|
|
@@ -179,7 +216,9 @@ async function resolveAllowedFingerprints(options) {
|
|
|
179
216
|
throw new HostVerificationError("A host is required for SSH host verification.");
|
|
180
217
|
}
|
|
181
218
|
if (options.hostFingerprint) {
|
|
182
|
-
|
|
219
|
+
for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
|
|
220
|
+
fingerprints.add(fingerprint);
|
|
221
|
+
}
|
|
183
222
|
}
|
|
184
223
|
try {
|
|
185
224
|
await access2(expandHome(knownHostsFile));
|
|
@@ -190,11 +229,15 @@ async function resolveAllowedFingerprints(options) {
|
|
|
190
229
|
continue;
|
|
191
230
|
}
|
|
192
231
|
const [hosts, algorithm, key] = trimmed.split(/\s+/);
|
|
193
|
-
|
|
232
|
+
const hostPatterns = hosts?.split(",") ?? [];
|
|
233
|
+
const portHost = options.port ? `[${options.host}]:${options.port}` : void 0;
|
|
234
|
+
const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
|
|
235
|
+
if (!hosts || !algorithm || !key || !matchesHost) {
|
|
194
236
|
continue;
|
|
195
237
|
}
|
|
196
|
-
const
|
|
197
|
-
fingerprints.add(
|
|
238
|
+
const keyBuffer = Buffer.from(key, "base64");
|
|
239
|
+
fingerprints.add(createHash("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
|
|
240
|
+
fingerprints.add(createHash("sha256").update(keyBuffer).digest("hex"));
|
|
198
241
|
}
|
|
199
242
|
} catch (error) {
|
|
200
243
|
if (options.knownHostsFile || !options.hostFingerprint) {
|
|
@@ -206,7 +249,9 @@ async function resolveAllowedFingerprints(options) {
|
|
|
206
249
|
}
|
|
207
250
|
if (fingerprints.size === 0) {
|
|
208
251
|
throw new HostVerificationError(
|
|
209
|
-
|
|
252
|
+
`SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
|
|
253
|
+
options.host
|
|
254
|
+
)}`
|
|
210
255
|
);
|
|
211
256
|
}
|
|
212
257
|
return fingerprints;
|
|
@@ -233,7 +278,11 @@ function toRemoteFileInfo(info) {
|
|
|
233
278
|
var Ssh2SftpTransport = class {
|
|
234
279
|
client;
|
|
235
280
|
constructor(name = "scp-next") {
|
|
236
|
-
this.client = new SftpClient(name
|
|
281
|
+
this.client = new SftpClient(name, {
|
|
282
|
+
error: () => void 0,
|
|
283
|
+
end: () => void 0,
|
|
284
|
+
close: () => void 0
|
|
285
|
+
});
|
|
237
286
|
}
|
|
238
287
|
async connect(options) {
|
|
239
288
|
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
@@ -263,15 +312,24 @@ var Ssh2SftpTransport = class {
|
|
|
263
312
|
} catch (error) {
|
|
264
313
|
const converted = toScpNextError(error);
|
|
265
314
|
if (converted instanceof HostVerificationError) {
|
|
266
|
-
throw
|
|
315
|
+
throw new HostVerificationError(
|
|
316
|
+
`SSH host verification failed during connection: ${formatErrorMessage(
|
|
317
|
+
error
|
|
318
|
+
)}.${hostVerificationTroubleshooting(options.host)}`,
|
|
319
|
+
{ cause: error, context: connectionContext(options) }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (isLikelyHostVerificationFailure(error)) {
|
|
323
|
+
throw new HostVerificationError(
|
|
324
|
+
`SSH host verification failed during connection.${hostVerificationTroubleshooting(
|
|
325
|
+
options.host
|
|
326
|
+
)}`,
|
|
327
|
+
{ cause: error, context: connectionContext(options) }
|
|
328
|
+
);
|
|
267
329
|
}
|
|
268
|
-
throw new ConnectionError(
|
|
330
|
+
throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
|
|
269
331
|
cause: error,
|
|
270
|
-
context:
|
|
271
|
-
host: options.host,
|
|
272
|
-
port: options.port,
|
|
273
|
-
username: options.username
|
|
274
|
-
}
|
|
332
|
+
context: connectionContext(options)
|
|
275
333
|
});
|
|
276
334
|
}
|
|
277
335
|
}
|
|
@@ -292,7 +350,7 @@ var Ssh2SftpTransport = class {
|
|
|
292
350
|
try {
|
|
293
351
|
await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
|
|
294
352
|
} catch (error) {
|
|
295
|
-
throw new TransferError(`Upload failed for ${localPath}
|
|
353
|
+
throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
|
|
296
354
|
cause: error,
|
|
297
355
|
context: { localPath, remotePath }
|
|
298
356
|
});
|
|
@@ -302,7 +360,7 @@ var Ssh2SftpTransport = class {
|
|
|
302
360
|
try {
|
|
303
361
|
await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
|
|
304
362
|
} catch (error) {
|
|
305
|
-
throw new TransferError(`Download failed for ${remotePath}
|
|
363
|
+
throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
|
|
306
364
|
cause: error,
|
|
307
365
|
context: { remotePath, localPath }
|
|
308
366
|
});
|
|
@@ -319,6 +377,31 @@ var Ssh2SftpTransport = class {
|
|
|
319
377
|
};
|
|
320
378
|
}
|
|
321
379
|
};
|
|
380
|
+
function connectionContext(options) {
|
|
381
|
+
return {
|
|
382
|
+
host: options.host,
|
|
383
|
+
port: options.port,
|
|
384
|
+
username: options.username
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function isLikelyHostVerificationFailure(error) {
|
|
388
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
389
|
+
return message.includes("host") && (message.includes("verif") || message.includes("fingerprint") || message.includes("host key") || message.includes("key mismatch") || message.includes("denied"));
|
|
390
|
+
}
|
|
391
|
+
function hostVerificationTroubleshooting(host) {
|
|
392
|
+
const displayHost = host ?? "<host>";
|
|
393
|
+
return [
|
|
394
|
+
"",
|
|
395
|
+
"",
|
|
396
|
+
"Typical fix:",
|
|
397
|
+
` ssh ${displayHost}`,
|
|
398
|
+
"",
|
|
399
|
+
"If plain ssh works but scp-next fails, check that the host entry is available in",
|
|
400
|
+
"`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
|
|
401
|
+
"OpenSSH usually stores entries as `[host]:port`.",
|
|
402
|
+
"You can also configure hostFingerprint or knownHostsFile explicitly."
|
|
403
|
+
].join("\n");
|
|
404
|
+
}
|
|
322
405
|
|
|
323
406
|
// src/client/walk.ts
|
|
324
407
|
import { readdir, stat as stat2 } from "fs/promises";
|
|
@@ -489,6 +572,22 @@ function percentage(transferredBytes, totalBytes) {
|
|
|
489
572
|
function mergeOptions(serverOptions, transferOptions) {
|
|
490
573
|
return { ...serverOptions, ...transferOptions ?? {} };
|
|
491
574
|
}
|
|
575
|
+
function hasTrailingPathSeparator(inputPath) {
|
|
576
|
+
return /[\\/]$/.test(inputPath);
|
|
577
|
+
}
|
|
578
|
+
function trimmedRemoteDirectory(remotePath) {
|
|
579
|
+
return remotePath.replace(/\/+$/g, "") || "/";
|
|
580
|
+
}
|
|
581
|
+
function remoteBasename(remotePath) {
|
|
582
|
+
return path4.posix.basename(trimmedRemoteDirectory(remotePath));
|
|
583
|
+
}
|
|
584
|
+
async function isExistingLocalDirectory(localPath) {
|
|
585
|
+
try {
|
|
586
|
+
return (await stat3(localPath)).isDirectory();
|
|
587
|
+
} catch {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
492
591
|
var ScpNextClientImpl = class {
|
|
493
592
|
serverOptions;
|
|
494
593
|
transport;
|
|
@@ -546,10 +645,9 @@ var ScpNextClientImpl = class {
|
|
|
546
645
|
throw new ValidationError("Uploading a directory requires recursive mode.");
|
|
547
646
|
}
|
|
548
647
|
if (localKind === "file") {
|
|
549
|
-
await this.
|
|
550
|
-
await this.assertRemoteOverwrite(remotePath, options.overwrite);
|
|
648
|
+
const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
|
|
551
649
|
const totalBytes2 = void 0;
|
|
552
|
-
await this.transport.uploadFile(resolveLocalPath(options.localPath),
|
|
650
|
+
await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
|
|
553
651
|
options.onProgress?.({
|
|
554
652
|
operation: "upload",
|
|
555
653
|
source: options.localPath,
|
|
@@ -566,22 +664,15 @@ var ScpNextClientImpl = class {
|
|
|
566
664
|
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
567
665
|
let completedBytes = 0;
|
|
568
666
|
let completedFiles = 0;
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
if (!destinationExists) {
|
|
576
|
-
if (!options.createDirectories) {
|
|
577
|
-
throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
|
|
578
|
-
context: { remotePath }
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
await this.transport.mkdir(remotePath, true);
|
|
582
|
-
}
|
|
667
|
+
const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(
|
|
668
|
+
options,
|
|
669
|
+
remotePath
|
|
670
|
+
);
|
|
583
671
|
for (const file of files) {
|
|
584
|
-
const destinationFile = remoteJoin(
|
|
672
|
+
const destinationFile = remoteJoin(
|
|
673
|
+
destinationRoot,
|
|
674
|
+
file.relativePath.split(path4.sep).join("/")
|
|
675
|
+
);
|
|
585
676
|
await this.ensureRemoteParent(destinationFile, true);
|
|
586
677
|
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
587
678
|
await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
|
|
@@ -602,6 +693,68 @@ var ScpNextClientImpl = class {
|
|
|
602
693
|
completedFiles += 1;
|
|
603
694
|
}
|
|
604
695
|
}
|
|
696
|
+
async resolveRemoteDestinationForLocalFile(options, remotePath) {
|
|
697
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
698
|
+
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
699
|
+
if (!isDirectoryLikeDestination) {
|
|
700
|
+
await this.ensureRemoteParent(remotePath, options.createDirectories);
|
|
701
|
+
await this.assertRemoteOverwrite(remotePath, options.overwrite);
|
|
702
|
+
return remotePath;
|
|
703
|
+
}
|
|
704
|
+
if (remoteKind && remoteKind !== "d") {
|
|
705
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
706
|
+
context: { remotePath }
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
|
|
710
|
+
if (!remoteKind) {
|
|
711
|
+
if (!options.createDirectories) {
|
|
712
|
+
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
713
|
+
context: { remotePath }
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
await this.transport.mkdir(remoteDirectory, true);
|
|
717
|
+
}
|
|
718
|
+
const destinationFile = remoteJoin(
|
|
719
|
+
remoteDirectory,
|
|
720
|
+
path4.basename(resolveLocalPath(options.localPath))
|
|
721
|
+
);
|
|
722
|
+
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
723
|
+
return destinationFile;
|
|
724
|
+
}
|
|
725
|
+
async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
|
|
726
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
727
|
+
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
728
|
+
const localDirectoryName = path4.basename(resolveLocalPath(options.localPath));
|
|
729
|
+
if (remoteKind && remoteKind !== "d") {
|
|
730
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
731
|
+
context: { remotePath }
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
if (isDirectoryLikeDestination) {
|
|
735
|
+
const remoteDirectory = trimmedRemoteDirectory(remotePath);
|
|
736
|
+
if (!remoteKind) {
|
|
737
|
+
if (!options.createDirectories) {
|
|
738
|
+
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
739
|
+
context: { remotePath }
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
await this.transport.mkdir(remoteDirectory, true);
|
|
743
|
+
}
|
|
744
|
+
const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
|
|
745
|
+
await this.ensureRemoteDirectory(destinationRoot);
|
|
746
|
+
return destinationRoot;
|
|
747
|
+
}
|
|
748
|
+
if (!remoteKind) {
|
|
749
|
+
if (!options.createDirectories) {
|
|
750
|
+
throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
|
|
751
|
+
context: { remotePath }
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
await this.transport.mkdir(remotePath, true);
|
|
755
|
+
}
|
|
756
|
+
return remotePath;
|
|
757
|
+
}
|
|
605
758
|
async downloadWithTransport(options) {
|
|
606
759
|
const remotePath = normalizeRemotePath(options.remotePath);
|
|
607
760
|
const remoteKind = await this.transport.exists(remotePath);
|
|
@@ -620,7 +773,7 @@ var ScpNextClientImpl = class {
|
|
|
620
773
|
await this.downloadSingleFile(options, remotePath);
|
|
621
774
|
}
|
|
622
775
|
async downloadSingleFile(options, remotePath) {
|
|
623
|
-
const localPath =
|
|
776
|
+
const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
|
|
624
777
|
await this.assertLocalOverwrite(localPath, options.overwrite);
|
|
625
778
|
if (options.createDirectories) {
|
|
626
779
|
await mkdir2(path4.dirname(localPath), { recursive: true });
|
|
@@ -639,7 +792,7 @@ var ScpNextClientImpl = class {
|
|
|
639
792
|
}
|
|
640
793
|
async downloadDirectory(options) {
|
|
641
794
|
const remotePath = normalizeRemotePath(options.remotePath);
|
|
642
|
-
const localRoot =
|
|
795
|
+
const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
|
|
643
796
|
const files = await walkRemoteFiles(this.transport, remotePath);
|
|
644
797
|
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
645
798
|
let completedBytes = 0;
|
|
@@ -667,6 +820,38 @@ var ScpNextClientImpl = class {
|
|
|
667
820
|
completedFiles += 1;
|
|
668
821
|
}
|
|
669
822
|
}
|
|
823
|
+
async resolveLocalDestinationForRemoteFile(options, remotePath) {
|
|
824
|
+
const localDestination = resolveLocalPath(options.localPath);
|
|
825
|
+
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
|
|
826
|
+
if (!isDirectoryLikeDestination) {
|
|
827
|
+
return localDestination;
|
|
828
|
+
}
|
|
829
|
+
if (!await isExistingLocalDirectory(localDestination)) {
|
|
830
|
+
if (!options.createDirectories) {
|
|
831
|
+
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
832
|
+
context: { localPath: options.localPath }
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
await mkdir2(localDestination, { recursive: true });
|
|
836
|
+
}
|
|
837
|
+
return path4.join(localDestination, remoteBasename(remotePath));
|
|
838
|
+
}
|
|
839
|
+
async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
|
|
840
|
+
const localDestination = resolveLocalPath(options.localPath);
|
|
841
|
+
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
|
|
842
|
+
if (!isDirectoryLikeDestination) {
|
|
843
|
+
return localDestination;
|
|
844
|
+
}
|
|
845
|
+
if (!await isExistingLocalDirectory(localDestination)) {
|
|
846
|
+
if (!options.createDirectories) {
|
|
847
|
+
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
848
|
+
context: { localPath: options.localPath }
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
await mkdir2(localDestination, { recursive: true });
|
|
852
|
+
}
|
|
853
|
+
return path4.join(localDestination, remoteBasename(remotePath));
|
|
854
|
+
}
|
|
670
855
|
async ensureRemoteParent(remotePath, createDirectories = false) {
|
|
671
856
|
const parent = remoteDirname(remotePath);
|
|
672
857
|
if (parent === "." || parent === "/") {
|
|
@@ -683,6 +868,17 @@ var ScpNextClientImpl = class {
|
|
|
683
868
|
});
|
|
684
869
|
}
|
|
685
870
|
}
|
|
871
|
+
async ensureRemoteDirectory(remotePath) {
|
|
872
|
+
const exists = await this.transport.exists(remotePath);
|
|
873
|
+
if (exists && exists !== "d") {
|
|
874
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
875
|
+
context: { remotePath }
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
if (!exists) {
|
|
879
|
+
await this.transport.mkdir(remotePath, true);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
686
882
|
async assertRemoteOverwrite(remotePath, overwrite = false) {
|
|
687
883
|
if (overwrite) {
|
|
688
884
|
return;
|