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/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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
fingerprints.add(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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),
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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;
|