scp-next 1.0.5 → 1.0.7

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,38 @@ 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
+ }
599
+ async function ensureLocalDirectory(localPath, createParents = false) {
600
+ try {
601
+ const stats = await stat3(localPath);
602
+ if (stats.isDirectory()) {
603
+ return;
604
+ }
605
+ throw new TransferError(`Local destination already exists and is not a directory: ${localPath}`, {
606
+ context: { localPath }
607
+ });
608
+ } catch (error) {
609
+ if (error instanceof TransferError) {
610
+ throw error;
611
+ }
612
+ await mkdir2(localPath, { recursive: createParents });
613
+ }
614
+ }
583
615
  var ScpNextClientImpl = class {
584
616
  serverOptions;
585
617
  transport;
@@ -656,22 +688,15 @@ var ScpNextClientImpl = class {
656
688
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
657
689
  let completedBytes = 0;
658
690
  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
- }
691
+ const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(
692
+ options,
693
+ remotePath
694
+ );
673
695
  for (const file of files) {
674
- const destinationFile = remoteJoin(remotePath, file.relativePath.split(path4.sep).join("/"));
696
+ const destinationFile = remoteJoin(
697
+ destinationRoot,
698
+ file.relativePath.split(path4.sep).join("/")
699
+ );
675
700
  await this.ensureRemoteParent(destinationFile, true);
676
701
  await this.assertRemoteOverwrite(destinationFile, options.overwrite);
677
702
  await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
@@ -721,6 +746,39 @@ var ScpNextClientImpl = class {
721
746
  await this.assertRemoteOverwrite(destinationFile, options.overwrite);
722
747
  return destinationFile;
723
748
  }
749
+ async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
750
+ const remoteKind = await this.transport.exists(remotePath);
751
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
752
+ const localDirectoryName = path4.basename(resolveLocalPath(options.localPath));
753
+ if (remoteKind && remoteKind !== "d") {
754
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
755
+ context: { remotePath }
756
+ });
757
+ }
758
+ if (isDirectoryLikeDestination) {
759
+ const remoteDirectory = trimmedRemoteDirectory(remotePath);
760
+ if (!remoteKind) {
761
+ if (!options.createDirectories) {
762
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
763
+ context: { remotePath }
764
+ });
765
+ }
766
+ await this.transport.mkdir(remoteDirectory, true);
767
+ }
768
+ const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
769
+ await this.ensureRemoteDirectory(destinationRoot);
770
+ return destinationRoot;
771
+ }
772
+ if (!remoteKind) {
773
+ if (!options.createDirectories) {
774
+ throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
775
+ context: { remotePath }
776
+ });
777
+ }
778
+ await this.transport.mkdir(remotePath, true);
779
+ }
780
+ return remotePath;
781
+ }
724
782
  async downloadWithTransport(options) {
725
783
  const remotePath = normalizeRemotePath(options.remotePath);
726
784
  const remoteKind = await this.transport.exists(remotePath);
@@ -739,7 +797,7 @@ var ScpNextClientImpl = class {
739
797
  await this.downloadSingleFile(options, remotePath);
740
798
  }
741
799
  async downloadSingleFile(options, remotePath) {
742
- const localPath = resolveLocalPath(options.localPath);
800
+ const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
743
801
  await this.assertLocalOverwrite(localPath, options.overwrite);
744
802
  if (options.createDirectories) {
745
803
  await mkdir2(path4.dirname(localPath), { recursive: true });
@@ -758,12 +816,12 @@ var ScpNextClientImpl = class {
758
816
  }
759
817
  async downloadDirectory(options) {
760
818
  const remotePath = normalizeRemotePath(options.remotePath);
761
- const localRoot = resolveLocalPath(options.localPath);
819
+ const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
762
820
  const files = await walkRemoteFiles(this.transport, remotePath);
763
821
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
764
822
  let completedBytes = 0;
765
823
  let completedFiles = 0;
766
- await mkdir2(localRoot, { recursive: Boolean(options.createDirectories) });
824
+ await ensureLocalDirectory(localRoot, Boolean(options.createDirectories));
767
825
  for (const file of files) {
768
826
  const localFile = path4.join(localRoot, ...file.relativePath.split("/"));
769
827
  await this.assertLocalOverwrite(localFile, options.overwrite);
@@ -786,6 +844,38 @@ var ScpNextClientImpl = class {
786
844
  completedFiles += 1;
787
845
  }
788
846
  }
847
+ async resolveLocalDestinationForRemoteFile(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
+ }
863
+ async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
864
+ const localDestination = resolveLocalPath(options.localPath);
865
+ const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
866
+ if (!isDirectoryLikeDestination) {
867
+ return localDestination;
868
+ }
869
+ if (!await isExistingLocalDirectory(localDestination)) {
870
+ if (!options.createDirectories) {
871
+ throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
872
+ context: { localPath: options.localPath }
873
+ });
874
+ }
875
+ await mkdir2(localDestination, { recursive: true });
876
+ }
877
+ return path4.join(localDestination, remoteBasename(remotePath));
878
+ }
789
879
  async ensureRemoteParent(remotePath, createDirectories = false) {
790
880
  const parent = remoteDirname(remotePath);
791
881
  if (parent === "." || parent === "/") {
@@ -802,6 +892,17 @@ var ScpNextClientImpl = class {
802
892
  });
803
893
  }
804
894
  }
895
+ async ensureRemoteDirectory(remotePath) {
896
+ const exists = await this.transport.exists(remotePath);
897
+ if (exists && exists !== "d") {
898
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
899
+ context: { remotePath }
900
+ });
901
+ }
902
+ if (!exists) {
903
+ await this.transport.mkdir(remotePath, true);
904
+ }
905
+ }
805
906
  async assertRemoteOverwrite(remotePath, overwrite = false) {
806
907
  if (overwrite) {
807
908
  return;