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 +17 -26
- package/dist/cli/index.js +103 -18
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +102 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +103 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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;
|