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 CHANGED
@@ -13,8 +13,7 @@
13
13
  [module-image]: https://img.shields.io/badge/module-ESM%20%2B%20CJS-blue
14
14
 
15
15
  `scp-next` is an SCP-style command-line tool and library for secure file transfers over SSH.
16
- The package uses SFTP internally through `ssh2-sftp-client` instead of implementing SCP or
17
- SFTP protocols manually.
16
+ The package uses SFTP internally through `ssh2-sftp-client` instead of implementing SCP or SFTP protocols manually.
18
17
 
19
18
  ## Features
20
19
 
@@ -45,6 +44,9 @@ scp-next run <job> [source] [destination] [options]
45
44
  CLI commands use `<source> <destination>`. Programmatic upload and download APIs use
46
45
  `localPath` and `remotePath`.
47
46
 
47
+ Destination paths follow familiar `cp`/`scp` behavior. If the destination exists as a directory or ends with a path separator, `scp-next` places the source inside that directory using the source basename.
48
+ Use `--create-directories` when a destination directory may be missing.
49
+
48
50
  ## Upload Examples
49
51
 
50
52
  ```bash
@@ -66,9 +68,8 @@ scp-next upload ./dist /var/www/example \
66
68
  --recursive
67
69
  ```
68
70
 
69
- Password arguments are convenient, but they may be exposed through shell history and process
70
- listings. Prefer `SCP_NEXT_PASSWORD`, SSH agents, or protected private-key files for shared
71
- or production environments.
71
+ Password arguments are convenient, but they may be exposed through shell history and process listings.
72
+ Prefer `SCP_NEXT_PASSWORD`, SSH agents, or protected private-key files for shared or production environments.
72
73
 
73
74
  ## Download Examples
74
75
 
@@ -248,8 +249,7 @@ Highest to lowest:
248
249
  6. Configured job values
249
250
  7. Internal defaults
250
251
 
251
- For `run`, job values are loaded first, then root configuration, selected profile,
252
- environment variables, explicit positional overrides, and CLI options.
252
+ For `run`, job values are loaded first, then root configuration, selected profile, environment variables, explicit positional overrides, and CLI options.
253
253
 
254
254
  ## Library Usage
255
255
 
@@ -322,23 +322,17 @@ Supported methods:
322
322
 
323
323
  ## Host Verification
324
324
 
325
- `hostFingerprint` compares the server host key SHA-256 fingerprint. `knownHostsFile`
326
- supports plain OpenSSH `known_hosts` entries for exact host names. When neither is supplied,
327
- `scp-next` reads `~/.ssh/known_hosts`. Hashed host names and every OpenSSH marker variant are
328
- not currently parsed.
325
+ `hostFingerprint` compares the server host key SHA-256 fingerprint. `knownHostsFile` supports plain OpenSSH `known_hosts` entries for exact host names. When neither is supplied, `scp-next` reads `~/.ssh/known_hosts`. Hashed host names and every OpenSSH marker variant are not currently parsed.
329
326
 
330
- `scp-next` fails closed if it cannot establish a host verifier. Use `hostFingerprint` for CI
331
- or deployments where a known-hosts file is not available.
327
+ `scp-next` fails closed if it cannot establish a host verifier. Use `hostFingerprint` for CI or deployments where a known-hosts file is not available.
332
328
 
333
329
  ## Progress Reporting
334
330
 
335
- Interactive terminals display concise progress. Progress is disabled with `--quiet` or when
336
- stderr is not a TTY, which keeps CI logs readable. Library users can pass `onProgress`.
331
+ Interactive terminals display concise progress. Progress is disabled with `--quiet` or when stderr is not a TTY, which keeps CI logs readable. Library users can pass `onProgress`.
337
332
 
338
333
  ## Dry Run
339
334
 
340
- `--dry-run` resolves configuration and validates local paths and transfer direction without
341
- connecting to the remote server or modifying local/remote files.
335
+ `--dry-run` resolves configuration and validates local paths and transfer direction without connecting to the remote server or modifying local/remote files.
342
336
 
343
337
  ```text
344
338
  Dry run: upload
@@ -361,8 +355,7 @@ Public typed errors:
361
355
  - `FileSystemError`
362
356
  - `HostVerificationError`
363
357
 
364
- Each error includes a stable `code`, readable `message`, optional `cause`, and redacted
365
- non-sensitive context.
358
+ Each error includes a stable `code`, readable `message`, optional `cause`, and redacted non-sensitive context.
366
359
 
367
360
  ```ts
368
361
  import { AuthenticationError, upload } from "scp-next";
@@ -394,8 +387,8 @@ createClient(options: ScpServerOptions): ScpNextClient
394
387
  copy(options: CopyOptions): Promise<void>
395
388
  ```
396
389
 
397
- Upload and download APIs use explicit `localPath` and `remotePath` names. The optional
398
- generic `copy()` API accepts typed local/remote endpoint objects.
390
+ Upload and download APIs use explicit `localPath` and `remotePath` names.
391
+ The optional generic `copy()` API accepts typed local/remote endpoint objects.
399
392
 
400
393
  ## Development
401
394
 
@@ -412,11 +405,9 @@ npm pack --dry-run
412
405
 
413
406
  ## Publishing
414
407
 
415
- The published package includes `dist`, README, license, changelog, and docs. The CLI entry is
416
- `dist/cli/index.js` and contains a Node.js shebang.
408
+ The published package includes `dist`, README, license, changelog, and docs. The CLI entry is `dist/cli/index.js` and contains a Node.js shebang.
417
409
 
418
410
  ## Transfer Mechanism
419
411
 
420
- Despite the package name, normal transfers use SFTP through `ssh2-sftp-client`, which is built
421
- on `ssh2`. This avoids remote shell execution for regular transfers and gives better support
422
- for progress reporting and recursive directory traversal than shelling out to an SCP command.
412
+ Despite the package name, normal transfers use SFTP through `ssh2-sftp-client`, which is built on `ssh2`.
413
+ This avoids remote shell execution for regular transfers and gives better support for progress reporting and recursive directory traversal than shelling out to an SCP command.
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
  import { Command as Command4, CommanderError } from "commander";
8
8
 
9
9
  // src/client/client.ts
10
- import { access as access4, mkdir as mkdir2 } from "fs/promises";
10
+ import { access as access4, mkdir as mkdir2, stat as stat3 } from "fs/promises";
11
11
  import path4 from "path";
12
12
 
13
13
  // src/client/transport.ts
@@ -109,7 +109,7 @@ function toScpNextError(error) {
109
109
  if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
110
110
  return new AuthenticationError("SSH authentication failed.", { cause: error });
111
111
  }
112
- if (lowerMessage.includes("host verification") || lowerMessage.includes("host key")) {
112
+ if (lowerMessage.includes("host verification") || lowerMessage.includes("host key") || lowerMessage.includes("fingerprint") || lowerMessage.includes("host") && lowerMessage.includes("denied")) {
113
113
  return new HostVerificationError("SSH host verification failed.", { cause: error });
114
114
  }
115
115
  if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
@@ -183,8 +183,39 @@ async function getLocalPathKind(inputPath) {
183
183
  function normalizeFingerprint(fingerprint) {
184
184
  return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
185
185
  }
186
+ function isHexFingerprint(fingerprint) {
187
+ return /^[0-9a-f]+$/i.test(fingerprint);
188
+ }
189
+ function fingerprintCandidates(fingerprint) {
190
+ const normalized = normalizeFingerprint(fingerprint);
191
+ const candidates = /* @__PURE__ */ new Set([normalized]);
192
+ if (isHexFingerprint(normalized)) {
193
+ candidates.add(normalized.toLowerCase());
194
+ } else {
195
+ try {
196
+ candidates.add(Buffer.from(normalized, "base64").toString("hex"));
197
+ } catch {
198
+ }
199
+ }
200
+ return [...candidates];
201
+ }
186
202
  function isFingerprintMatch(actual, expected) {
187
- return normalizeFingerprint(actual) === normalizeFingerprint(expected);
203
+ const normalizedActual = normalizeFingerprint(actual);
204
+ const normalizedExpected = normalizeFingerprint(expected);
205
+ if (normalizedActual === normalizedExpected) {
206
+ return true;
207
+ }
208
+ return isHexFingerprint(normalizedActual) && isHexFingerprint(normalizedExpected) && normalizedActual.toLowerCase() === normalizedExpected.toLowerCase();
209
+ }
210
+ function hostVerificationHint(host) {
211
+ return [
212
+ "",
213
+ "Typical fix:",
214
+ ` ssh ${host}`,
215
+ "",
216
+ "If you trust the host key, accept it and retry the scp-next command.",
217
+ "Alternatively configure hostFingerprint or knownHostsFile explicitly."
218
+ ].join("\n");
188
219
  }
189
220
  async function resolveAllowedFingerprints(options) {
190
221
  const fingerprints = /* @__PURE__ */ new Set();
@@ -193,7 +224,9 @@ async function resolveAllowedFingerprints(options) {
193
224
  throw new HostVerificationError("A host is required for SSH host verification.");
194
225
  }
195
226
  if (options.hostFingerprint) {
196
- fingerprints.add(normalizeFingerprint(options.hostFingerprint));
227
+ for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
228
+ fingerprints.add(fingerprint);
229
+ }
197
230
  }
198
231
  try {
199
232
  await access2(expandHome(knownHostsFile));
@@ -204,11 +237,15 @@ async function resolveAllowedFingerprints(options) {
204
237
  continue;
205
238
  }
206
239
  const [hosts, algorithm, key] = trimmed.split(/\s+/);
207
- if (!hosts || !algorithm || !key || !hosts.split(",").includes(options.host)) {
240
+ const hostPatterns = hosts?.split(",") ?? [];
241
+ const portHost = options.port ? `[${options.host}]:${options.port}` : void 0;
242
+ const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
243
+ if (!hosts || !algorithm || !key || !matchesHost) {
208
244
  continue;
209
245
  }
210
- const fingerprint = createHash("sha256").update(Buffer.from(key, "base64")).digest("base64").replace(/=+$/g, "");
211
- fingerprints.add(fingerprint);
246
+ const keyBuffer = Buffer.from(key, "base64");
247
+ fingerprints.add(createHash("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
248
+ fingerprints.add(createHash("sha256").update(keyBuffer).digest("hex"));
212
249
  }
213
250
  } catch (error) {
214
251
  if (options.knownHostsFile || !options.hostFingerprint) {
@@ -220,7 +257,9 @@ async function resolveAllowedFingerprints(options) {
220
257
  }
221
258
  if (fingerprints.size === 0) {
222
259
  throw new HostVerificationError(
223
- "SSH host verification failed before connecting: no matching host fingerprint was configured."
260
+ `SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
261
+ options.host
262
+ )}`
224
263
  );
225
264
  }
226
265
  return fingerprints;
@@ -247,7 +286,11 @@ function toRemoteFileInfo(info) {
247
286
  var Ssh2SftpTransport = class {
248
287
  client;
249
288
  constructor(name = "scp-next") {
250
- this.client = new SftpClient(name);
289
+ this.client = new SftpClient(name, {
290
+ error: () => void 0,
291
+ end: () => void 0,
292
+ close: () => void 0
293
+ });
251
294
  }
252
295
  async connect(options) {
253
296
  const allowedFingerprints = await resolveAllowedFingerprints(options);
@@ -277,15 +320,24 @@ var Ssh2SftpTransport = class {
277
320
  } catch (error) {
278
321
  const converted = toScpNextError(error);
279
322
  if (converted instanceof HostVerificationError) {
280
- throw converted;
323
+ throw new HostVerificationError(
324
+ `SSH host verification failed during connection: ${formatErrorMessage(
325
+ error
326
+ )}.${hostVerificationTroubleshooting(options.host)}`,
327
+ { cause: error, context: connectionContext(options) }
328
+ );
329
+ }
330
+ if (isLikelyHostVerificationFailure(error)) {
331
+ throw new HostVerificationError(
332
+ `SSH host verification failed during connection.${hostVerificationTroubleshooting(
333
+ options.host
334
+ )}`,
335
+ { cause: error, context: connectionContext(options) }
336
+ );
281
337
  }
282
- throw new ConnectionError("Unable to connect to SSH server.", {
338
+ throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
283
339
  cause: error,
284
- context: {
285
- host: options.host,
286
- port: options.port,
287
- username: options.username
288
- }
340
+ context: connectionContext(options)
289
341
  });
290
342
  }
291
343
  }
@@ -306,7 +358,7 @@ var Ssh2SftpTransport = class {
306
358
  try {
307
359
  await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
308
360
  } catch (error) {
309
- throw new TransferError(`Upload failed for ${localPath}.`, {
361
+ throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
310
362
  cause: error,
311
363
  context: { localPath, remotePath }
312
364
  });
@@ -316,7 +368,7 @@ var Ssh2SftpTransport = class {
316
368
  try {
317
369
  await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
318
370
  } catch (error) {
319
- throw new TransferError(`Download failed for ${remotePath}.`, {
371
+ throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
320
372
  cause: error,
321
373
  context: { remotePath, localPath }
322
374
  });
@@ -333,6 +385,31 @@ var Ssh2SftpTransport = class {
333
385
  };
334
386
  }
335
387
  };
388
+ function connectionContext(options) {
389
+ return {
390
+ host: options.host,
391
+ port: options.port,
392
+ username: options.username
393
+ };
394
+ }
395
+ function isLikelyHostVerificationFailure(error) {
396
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
397
+ return message.includes("host") && (message.includes("verif") || message.includes("fingerprint") || message.includes("host key") || message.includes("key mismatch") || message.includes("denied"));
398
+ }
399
+ function hostVerificationTroubleshooting(host) {
400
+ const displayHost = host ?? "<host>";
401
+ return [
402
+ "",
403
+ "",
404
+ "Typical fix:",
405
+ ` ssh ${displayHost}`,
406
+ "",
407
+ "If plain ssh works but scp-next fails, check that the host entry is available in",
408
+ "`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
409
+ "OpenSSH usually stores entries as `[host]:port`.",
410
+ "You can also configure hostFingerprint or knownHostsFile explicitly."
411
+ ].join("\n");
412
+ }
336
413
 
337
414
  // src/client/walk.ts
338
415
  import { readdir, stat as stat2 } from "fs/promises";
@@ -503,6 +580,22 @@ function percentage(transferredBytes, totalBytes) {
503
580
  function mergeOptions(serverOptions, transferOptions) {
504
581
  return { ...serverOptions, ...transferOptions ?? {} };
505
582
  }
583
+ function hasTrailingPathSeparator(inputPath) {
584
+ return /[\\/]$/.test(inputPath);
585
+ }
586
+ function trimmedRemoteDirectory(remotePath) {
587
+ return remotePath.replace(/\/+$/g, "") || "/";
588
+ }
589
+ function remoteBasename(remotePath) {
590
+ return path4.posix.basename(trimmedRemoteDirectory(remotePath));
591
+ }
592
+ async function isExistingLocalDirectory(localPath) {
593
+ try {
594
+ return (await stat3(localPath)).isDirectory();
595
+ } catch {
596
+ return false;
597
+ }
598
+ }
506
599
  var ScpNextClientImpl = class {
507
600
  serverOptions;
508
601
  transport;
@@ -560,10 +653,9 @@ var ScpNextClientImpl = class {
560
653
  throw new ValidationError("Uploading a directory requires recursive mode.");
561
654
  }
562
655
  if (localKind === "file") {
563
- await this.ensureRemoteParent(remotePath, options.createDirectories);
564
- await this.assertRemoteOverwrite(remotePath, options.overwrite);
656
+ const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
565
657
  const totalBytes2 = void 0;
566
- await this.transport.uploadFile(resolveLocalPath(options.localPath), remotePath, (step) => {
658
+ await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
567
659
  options.onProgress?.({
568
660
  operation: "upload",
569
661
  source: options.localPath,
@@ -580,22 +672,15 @@ var ScpNextClientImpl = class {
580
672
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
581
673
  let completedBytes = 0;
582
674
  let completedFiles = 0;
583
- const destinationExists = await this.transport.exists(remotePath);
584
- if (destinationExists && destinationExists !== "d") {
585
- throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
586
- context: { remotePath }
587
- });
588
- }
589
- if (!destinationExists) {
590
- if (!options.createDirectories) {
591
- throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
592
- context: { remotePath }
593
- });
594
- }
595
- await this.transport.mkdir(remotePath, true);
596
- }
675
+ const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(
676
+ options,
677
+ remotePath
678
+ );
597
679
  for (const file of files) {
598
- const destinationFile = remoteJoin(remotePath, file.relativePath.split(path4.sep).join("/"));
680
+ const destinationFile = remoteJoin(
681
+ destinationRoot,
682
+ file.relativePath.split(path4.sep).join("/")
683
+ );
599
684
  await this.ensureRemoteParent(destinationFile, true);
600
685
  await this.assertRemoteOverwrite(destinationFile, options.overwrite);
601
686
  await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
@@ -616,6 +701,68 @@ var ScpNextClientImpl = class {
616
701
  completedFiles += 1;
617
702
  }
618
703
  }
704
+ async resolveRemoteDestinationForLocalFile(options, remotePath) {
705
+ const remoteKind = await this.transport.exists(remotePath);
706
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
707
+ if (!isDirectoryLikeDestination) {
708
+ await this.ensureRemoteParent(remotePath, options.createDirectories);
709
+ await this.assertRemoteOverwrite(remotePath, options.overwrite);
710
+ return remotePath;
711
+ }
712
+ if (remoteKind && remoteKind !== "d") {
713
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
714
+ context: { remotePath }
715
+ });
716
+ }
717
+ const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
718
+ if (!remoteKind) {
719
+ if (!options.createDirectories) {
720
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
721
+ context: { remotePath }
722
+ });
723
+ }
724
+ await this.transport.mkdir(remoteDirectory, true);
725
+ }
726
+ const destinationFile = remoteJoin(
727
+ remoteDirectory,
728
+ path4.basename(resolveLocalPath(options.localPath))
729
+ );
730
+ await this.assertRemoteOverwrite(destinationFile, options.overwrite);
731
+ return destinationFile;
732
+ }
733
+ async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
734
+ const remoteKind = await this.transport.exists(remotePath);
735
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
736
+ const localDirectoryName = path4.basename(resolveLocalPath(options.localPath));
737
+ if (remoteKind && remoteKind !== "d") {
738
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
739
+ context: { remotePath }
740
+ });
741
+ }
742
+ if (isDirectoryLikeDestination) {
743
+ const remoteDirectory = trimmedRemoteDirectory(remotePath);
744
+ if (!remoteKind) {
745
+ if (!options.createDirectories) {
746
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
747
+ context: { remotePath }
748
+ });
749
+ }
750
+ await this.transport.mkdir(remoteDirectory, true);
751
+ }
752
+ const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
753
+ await this.ensureRemoteDirectory(destinationRoot);
754
+ return destinationRoot;
755
+ }
756
+ if (!remoteKind) {
757
+ if (!options.createDirectories) {
758
+ throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
759
+ context: { remotePath }
760
+ });
761
+ }
762
+ await this.transport.mkdir(remotePath, true);
763
+ }
764
+ return remotePath;
765
+ }
619
766
  async downloadWithTransport(options) {
620
767
  const remotePath = normalizeRemotePath(options.remotePath);
621
768
  const remoteKind = await this.transport.exists(remotePath);
@@ -634,7 +781,7 @@ var ScpNextClientImpl = class {
634
781
  await this.downloadSingleFile(options, remotePath);
635
782
  }
636
783
  async downloadSingleFile(options, remotePath) {
637
- const localPath = resolveLocalPath(options.localPath);
784
+ const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
638
785
  await this.assertLocalOverwrite(localPath, options.overwrite);
639
786
  if (options.createDirectories) {
640
787
  await mkdir2(path4.dirname(localPath), { recursive: true });
@@ -653,7 +800,7 @@ var ScpNextClientImpl = class {
653
800
  }
654
801
  async downloadDirectory(options) {
655
802
  const remotePath = normalizeRemotePath(options.remotePath);
656
- const localRoot = resolveLocalPath(options.localPath);
803
+ const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
657
804
  const files = await walkRemoteFiles(this.transport, remotePath);
658
805
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
659
806
  let completedBytes = 0;
@@ -681,6 +828,38 @@ var ScpNextClientImpl = class {
681
828
  completedFiles += 1;
682
829
  }
683
830
  }
831
+ async resolveLocalDestinationForRemoteFile(options, remotePath) {
832
+ const localDestination = resolveLocalPath(options.localPath);
833
+ const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
834
+ if (!isDirectoryLikeDestination) {
835
+ return localDestination;
836
+ }
837
+ if (!await isExistingLocalDirectory(localDestination)) {
838
+ if (!options.createDirectories) {
839
+ throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
840
+ context: { localPath: options.localPath }
841
+ });
842
+ }
843
+ await mkdir2(localDestination, { recursive: true });
844
+ }
845
+ return path4.join(localDestination, remoteBasename(remotePath));
846
+ }
847
+ async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
848
+ const localDestination = resolveLocalPath(options.localPath);
849
+ const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
850
+ if (!isDirectoryLikeDestination) {
851
+ return localDestination;
852
+ }
853
+ if (!await isExistingLocalDirectory(localDestination)) {
854
+ if (!options.createDirectories) {
855
+ throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
856
+ context: { localPath: options.localPath }
857
+ });
858
+ }
859
+ await mkdir2(localDestination, { recursive: true });
860
+ }
861
+ return path4.join(localDestination, remoteBasename(remotePath));
862
+ }
684
863
  async ensureRemoteParent(remotePath, createDirectories = false) {
685
864
  const parent = remoteDirname(remotePath);
686
865
  if (parent === "." || parent === "/") {
@@ -697,6 +876,17 @@ var ScpNextClientImpl = class {
697
876
  });
698
877
  }
699
878
  }
879
+ async ensureRemoteDirectory(remotePath) {
880
+ const exists = await this.transport.exists(remotePath);
881
+ if (exists && exists !== "d") {
882
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
883
+ context: { remotePath }
884
+ });
885
+ }
886
+ if (!exists) {
887
+ await this.transport.mkdir(remotePath, true);
888
+ }
889
+ }
700
890
  async assertRemoteOverwrite(remotePath, overwrite = false) {
701
891
  if (overwrite) {
702
892
  return;