scp-next 1.0.5 → 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
@@ -580,6 +580,22 @@ function percentage(transferredBytes, totalBytes) {
580
580
  function mergeOptions(serverOptions, transferOptions) {
581
581
  return { ...serverOptions, ...transferOptions ?? {} };
582
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
+ }
583
599
  var ScpNextClientImpl = class {
584
600
  serverOptions;
585
601
  transport;
@@ -656,22 +672,15 @@ var ScpNextClientImpl = class {
656
672
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
657
673
  let completedBytes = 0;
658
674
  let completedFiles = 0;
659
- const destinationExists = await this.transport.exists(remotePath);
660
- if (destinationExists && destinationExists !== "d") {
661
- throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
662
- context: { remotePath }
663
- });
664
- }
665
- if (!destinationExists) {
666
- if (!options.createDirectories) {
667
- throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
668
- context: { remotePath }
669
- });
670
- }
671
- await this.transport.mkdir(remotePath, true);
672
- }
675
+ const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(
676
+ options,
677
+ remotePath
678
+ );
673
679
  for (const file of files) {
674
- const destinationFile = remoteJoin(remotePath, file.relativePath.split(path4.sep).join("/"));
680
+ const destinationFile = remoteJoin(
681
+ destinationRoot,
682
+ file.relativePath.split(path4.sep).join("/")
683
+ );
675
684
  await this.ensureRemoteParent(destinationFile, true);
676
685
  await this.assertRemoteOverwrite(destinationFile, options.overwrite);
677
686
  await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
@@ -721,6 +730,39 @@ var ScpNextClientImpl = class {
721
730
  await this.assertRemoteOverwrite(destinationFile, options.overwrite);
722
731
  return destinationFile;
723
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
+ }
724
766
  async downloadWithTransport(options) {
725
767
  const remotePath = normalizeRemotePath(options.remotePath);
726
768
  const remoteKind = await this.transport.exists(remotePath);
@@ -739,7 +781,7 @@ var ScpNextClientImpl = class {
739
781
  await this.downloadSingleFile(options, remotePath);
740
782
  }
741
783
  async downloadSingleFile(options, remotePath) {
742
- const localPath = resolveLocalPath(options.localPath);
784
+ const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
743
785
  await this.assertLocalOverwrite(localPath, options.overwrite);
744
786
  if (options.createDirectories) {
745
787
  await mkdir2(path4.dirname(localPath), { recursive: true });
@@ -758,7 +800,7 @@ var ScpNextClientImpl = class {
758
800
  }
759
801
  async downloadDirectory(options) {
760
802
  const remotePath = normalizeRemotePath(options.remotePath);
761
- const localRoot = resolveLocalPath(options.localPath);
803
+ const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
762
804
  const files = await walkRemoteFiles(this.transport, remotePath);
763
805
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
764
806
  let completedBytes = 0;
@@ -786,6 +828,38 @@ var ScpNextClientImpl = class {
786
828
  completedFiles += 1;
787
829
  }
788
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
+ }
789
863
  async ensureRemoteParent(remotePath, createDirectories = false) {
790
864
  const parent = remoteDirname(remotePath);
791
865
  if (parent === "." || parent === "/") {
@@ -802,6 +876,17 @@ var ScpNextClientImpl = class {
802
876
  });
803
877
  }
804
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
+ }
805
890
  async assertRemoteOverwrite(remotePath, overwrite = false) {
806
891
  if (overwrite) {
807
892
  return;