scp-next 1.0.19 → 1.1.2
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/CHANGELOG.md +12 -1
- package/README.md +91 -35
- package/dist/cli/index.js +479 -71
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +391 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -5
- package/dist/index.d.ts +49 -5
- package/dist/index.js +390 -32
- package/dist/index.js.map +1 -1
- package/{docs → guides}/ARCHITECTURE.md +21 -4
- package/guides/mazey-candidates.md +364 -0
- package/guides/release-notes/introducing-scp-next-v1.0.19-en.md +347 -0
- package/{docs → guides}/release-notes/introducing-scp-next-v1.0.19-zh.md +3 -3
- package/package.json +9 -4
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.14.md +0 -0
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.2.md +0 -0
package/dist/index.js
CHANGED
|
@@ -3,7 +3,6 @@ import { access as access4, mkdir as mkdir2, stat as stat3 } from "fs/promises";
|
|
|
3
3
|
import path4 from "path";
|
|
4
4
|
|
|
5
5
|
// src/client/transport.ts
|
|
6
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
7
6
|
import SftpClient from "ssh2-sftp-client";
|
|
8
7
|
|
|
9
8
|
// src/security/redact.ts
|
|
@@ -18,6 +17,8 @@ var SENSITIVE_KEYS = [
|
|
|
18
17
|
"secret"
|
|
19
18
|
];
|
|
20
19
|
var PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
20
|
+
var SECRET_OPTION_PATTERN = /(--(?:password|passphrase|token|secret|authorization|credential)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi;
|
|
21
|
+
var SECRET_ASSIGNMENT_PATTERN = /(\b(?:password|passphrase|token|secret|authorization|credential)[A-Z0-9_-]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s&]+)/gi;
|
|
21
22
|
function isSensitiveKey(key) {
|
|
22
23
|
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
23
24
|
return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
|
|
@@ -41,9 +42,34 @@ function redactSensitiveValues(value) {
|
|
|
41
42
|
}
|
|
42
43
|
function formatErrorMessage(error) {
|
|
43
44
|
if (error instanceof Error) {
|
|
44
|
-
return
|
|
45
|
+
return redactKnownSensitiveValues(error.message, {});
|
|
46
|
+
}
|
|
47
|
+
return redactKnownSensitiveValues(String(error), {});
|
|
48
|
+
}
|
|
49
|
+
function redactKnownSensitiveValues(text, source) {
|
|
50
|
+
const sensitiveValues = collectSensitiveValues(source);
|
|
51
|
+
const redactedKnownValues = sensitiveValues.reduce(
|
|
52
|
+
(redacted, value) => redacted.split(value).join("[REDACTED]"),
|
|
53
|
+
String(redactSensitiveValues(text))
|
|
54
|
+
);
|
|
55
|
+
return redactedKnownValues.replace(SECRET_OPTION_PATTERN, "$1[REDACTED]").replace(SECRET_ASSIGNMENT_PATTERN, "$1[REDACTED]");
|
|
56
|
+
}
|
|
57
|
+
function collectSensitiveValues(value, key = "") {
|
|
58
|
+
if (isSensitiveKey(key)) {
|
|
59
|
+
if (typeof value === "string" && value) {
|
|
60
|
+
return [value];
|
|
61
|
+
}
|
|
62
|
+
if (Buffer.isBuffer(value) && value.length > 0) {
|
|
63
|
+
return [value.toString("utf8")];
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
if (!value || typeof value !== "object") {
|
|
68
|
+
return [];
|
|
45
69
|
}
|
|
46
|
-
return
|
|
70
|
+
return Object.entries(value).flatMap(
|
|
71
|
+
([nestedKey, nestedValue]) => collectSensitiveValues(nestedValue, nestedKey)
|
|
72
|
+
);
|
|
47
73
|
}
|
|
48
74
|
|
|
49
75
|
// src/errors/index.ts
|
|
@@ -82,6 +108,23 @@ var TransferError = class extends ScpNextError {
|
|
|
82
108
|
super(message, { ...options, code: "SCP_NEXT_TRANSFER_ERROR" });
|
|
83
109
|
}
|
|
84
110
|
};
|
|
111
|
+
var RemoteCommandError = class extends ScpNextError {
|
|
112
|
+
exitCode;
|
|
113
|
+
signal;
|
|
114
|
+
stdout;
|
|
115
|
+
stderr;
|
|
116
|
+
constructor(message, options) {
|
|
117
|
+
super(message, {
|
|
118
|
+
code: "SCP_NEXT_REMOTE_COMMAND_ERROR",
|
|
119
|
+
...options.cause !== void 0 ? { cause: options.cause } : {},
|
|
120
|
+
...options.context !== void 0 ? { context: options.context } : {}
|
|
121
|
+
});
|
|
122
|
+
this.exitCode = options.exitCode;
|
|
123
|
+
this.signal = options.signal;
|
|
124
|
+
this.stdout = options.stdout ?? "";
|
|
125
|
+
this.stderr = options.stderr ?? "";
|
|
126
|
+
}
|
|
127
|
+
};
|
|
85
128
|
var FileSystemError = class extends ScpNextError {
|
|
86
129
|
constructor(message, options = {}) {
|
|
87
130
|
super(message, { ...options, code: "SCP_NEXT_FILESYSTEM_ERROR" });
|
|
@@ -110,6 +153,9 @@ function toScpNextError(error) {
|
|
|
110
153
|
return new TransferError(String(redactSensitiveValues(message)), { cause: error });
|
|
111
154
|
}
|
|
112
155
|
|
|
156
|
+
// src/client/ssh-options.ts
|
|
157
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
158
|
+
|
|
113
159
|
// src/security/host-verification.ts
|
|
114
160
|
import { createHash } from "crypto";
|
|
115
161
|
import { access as access2, readFile } from "fs/promises";
|
|
@@ -267,6 +313,34 @@ function createHostVerifier(allowedFingerprints) {
|
|
|
267
313
|
};
|
|
268
314
|
}
|
|
269
315
|
|
|
316
|
+
// src/client/ssh-options.ts
|
|
317
|
+
async function createSshConnectOptions(options) {
|
|
318
|
+
const connectOptions = {};
|
|
319
|
+
if (options.host !== void 0) connectOptions.host = options.host;
|
|
320
|
+
if (options.port !== void 0) connectOptions.port = options.port;
|
|
321
|
+
if (options.username !== void 0) connectOptions.username = options.username;
|
|
322
|
+
if (options.password !== void 0) connectOptions.password = options.password;
|
|
323
|
+
if (options.privateKey !== void 0) {
|
|
324
|
+
connectOptions.privateKey = options.privateKey;
|
|
325
|
+
} else if (options.privateKeyFile) {
|
|
326
|
+
connectOptions.privateKey = await readFile2(
|
|
327
|
+
expandHome(options.privateKeyFile),
|
|
328
|
+
"utf8"
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
|
|
332
|
+
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
333
|
+
if (agent !== void 0) connectOptions.agent = agent;
|
|
334
|
+
if (options.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
|
|
335
|
+
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
336
|
+
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
337
|
+
if (hostVerifier) {
|
|
338
|
+
connectOptions.hostHash = "sha256";
|
|
339
|
+
connectOptions.hostVerifier = hostVerifier;
|
|
340
|
+
}
|
|
341
|
+
return connectOptions;
|
|
342
|
+
}
|
|
343
|
+
|
|
270
344
|
// src/client/transport.ts
|
|
271
345
|
function toRemoteFileInfo(info) {
|
|
272
346
|
return {
|
|
@@ -285,28 +359,7 @@ var Ssh2SftpTransport = class {
|
|
|
285
359
|
});
|
|
286
360
|
}
|
|
287
361
|
async connect(options) {
|
|
288
|
-
const
|
|
289
|
-
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
290
|
-
const connectOptions = {};
|
|
291
|
-
if (options.host !== void 0) connectOptions.host = options.host;
|
|
292
|
-
if (options.port !== void 0) connectOptions.port = options.port;
|
|
293
|
-
if (options.username !== void 0) connectOptions.username = options.username;
|
|
294
|
-
if (options.password !== void 0) connectOptions.password = options.password;
|
|
295
|
-
if (options.privateKey !== void 0) {
|
|
296
|
-
connectOptions.privateKey = options.privateKey;
|
|
297
|
-
} else if (options.privateKeyFile) {
|
|
298
|
-
connectOptions.privateKey = await readFile2(expandHome(options.privateKeyFile), "utf8");
|
|
299
|
-
}
|
|
300
|
-
if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
|
|
301
|
-
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
302
|
-
if (agent !== void 0) {
|
|
303
|
-
connectOptions.agent = agent;
|
|
304
|
-
}
|
|
305
|
-
if (options.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
|
|
306
|
-
if (hostVerifier) {
|
|
307
|
-
connectOptions.hostHash = "sha256";
|
|
308
|
-
connectOptions.hostVerifier = hostVerifier;
|
|
309
|
-
}
|
|
362
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
310
363
|
try {
|
|
311
364
|
await this.client.connect(connectOptions);
|
|
312
365
|
} catch (error) {
|
|
@@ -403,6 +456,210 @@ function hostVerificationTroubleshooting(host) {
|
|
|
403
456
|
].join("\n");
|
|
404
457
|
}
|
|
405
458
|
|
|
459
|
+
// src/client/command-executor.ts
|
|
460
|
+
import { Client } from "ssh2";
|
|
461
|
+
var SshCommandExecutor = class {
|
|
462
|
+
client;
|
|
463
|
+
connected = false;
|
|
464
|
+
closed = true;
|
|
465
|
+
constructor(client = new Client()) {
|
|
466
|
+
this.client = client;
|
|
467
|
+
this.client.on("error", () => {
|
|
468
|
+
this.connected = false;
|
|
469
|
+
});
|
|
470
|
+
this.client.on("close", () => {
|
|
471
|
+
this.connected = false;
|
|
472
|
+
this.closed = true;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
async connect(options) {
|
|
476
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
477
|
+
this.closed = false;
|
|
478
|
+
await new Promise((resolve, reject) => {
|
|
479
|
+
const onReady = () => {
|
|
480
|
+
cleanup();
|
|
481
|
+
this.connected = true;
|
|
482
|
+
resolve();
|
|
483
|
+
};
|
|
484
|
+
const onError = (error) => {
|
|
485
|
+
cleanup();
|
|
486
|
+
this.connected = false;
|
|
487
|
+
this.closed = true;
|
|
488
|
+
try {
|
|
489
|
+
this.client.end();
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
492
|
+
const converted = toScpNextError(error);
|
|
493
|
+
if (converted instanceof HostVerificationError) {
|
|
494
|
+
reject(converted);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
reject(
|
|
498
|
+
new ConnectionError(
|
|
499
|
+
`Unable to connect command executor to SSH server: ${formatErrorMessage(error)}`,
|
|
500
|
+
{
|
|
501
|
+
cause: error,
|
|
502
|
+
context: {
|
|
503
|
+
host: options.host,
|
|
504
|
+
port: options.port,
|
|
505
|
+
username: options.username
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
);
|
|
510
|
+
};
|
|
511
|
+
const onClose = () => {
|
|
512
|
+
cleanup();
|
|
513
|
+
reject(new ConnectionError("SSH connection closed before it was ready."));
|
|
514
|
+
};
|
|
515
|
+
const cleanup = () => {
|
|
516
|
+
this.client.off("ready", onReady);
|
|
517
|
+
this.client.off("error", onError);
|
|
518
|
+
this.client.off("close", onClose);
|
|
519
|
+
};
|
|
520
|
+
this.client.once("ready", onReady);
|
|
521
|
+
this.client.once("error", onError);
|
|
522
|
+
this.client.once("close", onClose);
|
|
523
|
+
try {
|
|
524
|
+
this.client.connect(connectOptions);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
cleanup();
|
|
527
|
+
this.closed = true;
|
|
528
|
+
reject(
|
|
529
|
+
new ConnectionError(
|
|
530
|
+
`Unable to start SSH connection: ${formatErrorMessage(error)}`
|
|
531
|
+
)
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
async exec(command, options = {}) {
|
|
537
|
+
if (!this.connected) {
|
|
538
|
+
throw new ConnectionError("Command executor is not connected.");
|
|
539
|
+
}
|
|
540
|
+
return new Promise((resolve, reject) => {
|
|
541
|
+
try {
|
|
542
|
+
this.client.exec(command, (error, stream) => {
|
|
543
|
+
if (error) {
|
|
544
|
+
reject(
|
|
545
|
+
new RemoteCommandError("Unable to start remote command.", {
|
|
546
|
+
exitCode: null
|
|
547
|
+
})
|
|
548
|
+
);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
collectCommandResult(command, stream, options, resolve, reject);
|
|
552
|
+
});
|
|
553
|
+
} catch (error) {
|
|
554
|
+
reject(
|
|
555
|
+
new ConnectionError(
|
|
556
|
+
`Unable to start remote command: ${formatErrorMessage(error)}`
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
async close() {
|
|
563
|
+
if (this.closed) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
this.connected = false;
|
|
567
|
+
await new Promise((resolve) => {
|
|
568
|
+
this.client.once("close", resolve);
|
|
569
|
+
this.client.end();
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
function collectCommandResult(command, stream, options, resolve, reject) {
|
|
574
|
+
let stdout = "";
|
|
575
|
+
let stderr = "";
|
|
576
|
+
let outputBytes = 0;
|
|
577
|
+
let settled = false;
|
|
578
|
+
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024;
|
|
579
|
+
const cleanup = () => {
|
|
580
|
+
if (timer) clearTimeout(timer);
|
|
581
|
+
stream.off("data", onStdoutData);
|
|
582
|
+
stream.stderr.off("data", onStderrData);
|
|
583
|
+
stream.off("close", onClose);
|
|
584
|
+
stream.off("error", onStreamError);
|
|
585
|
+
stream.stderr.off("error", onStreamError);
|
|
586
|
+
};
|
|
587
|
+
const fail = (error) => {
|
|
588
|
+
if (settled) return;
|
|
589
|
+
settled = true;
|
|
590
|
+
cleanup();
|
|
591
|
+
try {
|
|
592
|
+
stream.close();
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
reject(error);
|
|
596
|
+
};
|
|
597
|
+
const appendOutput = (target, chunk) => {
|
|
598
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
599
|
+
if (outputBytes > maxBuffer) {
|
|
600
|
+
fail(
|
|
601
|
+
new RemoteCommandError(
|
|
602
|
+
`Remote command output exceeded maxBuffer of ${maxBuffer} bytes.`,
|
|
603
|
+
{
|
|
604
|
+
exitCode: null,
|
|
605
|
+
stdout,
|
|
606
|
+
stderr
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (target === "stdout") stdout += chunk;
|
|
613
|
+
else stderr += chunk;
|
|
614
|
+
};
|
|
615
|
+
const onStreamError = () => {
|
|
616
|
+
fail(
|
|
617
|
+
new RemoteCommandError("Remote command stream failed.", {
|
|
618
|
+
exitCode: null,
|
|
619
|
+
stdout,
|
|
620
|
+
stderr
|
|
621
|
+
})
|
|
622
|
+
);
|
|
623
|
+
};
|
|
624
|
+
const onStdoutData = (chunk) => {
|
|
625
|
+
appendOutput("stdout", chunk);
|
|
626
|
+
};
|
|
627
|
+
const onStderrData = (chunk) => {
|
|
628
|
+
appendOutput("stderr", chunk);
|
|
629
|
+
};
|
|
630
|
+
const onClose = (exitCode, signal) => {
|
|
631
|
+
if (settled) return;
|
|
632
|
+
settled = true;
|
|
633
|
+
cleanup();
|
|
634
|
+
resolve({
|
|
635
|
+
command,
|
|
636
|
+
stdout,
|
|
637
|
+
stderr,
|
|
638
|
+
exitCode: typeof exitCode === "number" ? exitCode : null,
|
|
639
|
+
...signal ? { signal } : {}
|
|
640
|
+
});
|
|
641
|
+
};
|
|
642
|
+
const timer = options.timeout === void 0 ? void 0 : setTimeout(() => {
|
|
643
|
+
fail(
|
|
644
|
+
new RemoteCommandError(
|
|
645
|
+
`Remote command timed out after ${options.timeout} ms.`,
|
|
646
|
+
{
|
|
647
|
+
exitCode: null,
|
|
648
|
+
stdout,
|
|
649
|
+
stderr
|
|
650
|
+
}
|
|
651
|
+
)
|
|
652
|
+
);
|
|
653
|
+
}, options.timeout);
|
|
654
|
+
stream.setEncoding("utf8");
|
|
655
|
+
stream.stderr.setEncoding("utf8");
|
|
656
|
+
stream.on("data", onStdoutData);
|
|
657
|
+
stream.stderr.on("data", onStderrData);
|
|
658
|
+
stream.once("error", onStreamError);
|
|
659
|
+
stream.stderr.once("error", onStreamError);
|
|
660
|
+
stream.once("close", onClose);
|
|
661
|
+
}
|
|
662
|
+
|
|
406
663
|
// src/client/walk.ts
|
|
407
664
|
import { readdir, stat as stat2 } from "fs/promises";
|
|
408
665
|
import path3 from "path";
|
|
@@ -487,6 +744,33 @@ async function walkRemoteFiles(transport, rootPath) {
|
|
|
487
744
|
|
|
488
745
|
// src/config/validation.ts
|
|
489
746
|
import { access as access3 } from "fs/promises";
|
|
747
|
+
|
|
748
|
+
// src/config/post-upload-commands.ts
|
|
749
|
+
function isPostUploadCommands(value) {
|
|
750
|
+
return Array.isArray(value) && value.every(
|
|
751
|
+
(command) => typeof command === "string" && command.trim().length > 0
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
function getPostUploadCommands(source) {
|
|
755
|
+
const commands = readPostUploadCommands(source);
|
|
756
|
+
if (commands === void 0) {
|
|
757
|
+
return void 0;
|
|
758
|
+
}
|
|
759
|
+
if (!isPostUploadCommands(commands)) {
|
|
760
|
+
throw new ValidationError(
|
|
761
|
+
"postUploadCommands must be an array of non-empty command strings."
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
return commands;
|
|
765
|
+
}
|
|
766
|
+
function readPostUploadCommands(source) {
|
|
767
|
+
if (typeof source !== "object" || source === null) {
|
|
768
|
+
return void 0;
|
|
769
|
+
}
|
|
770
|
+
return source.postUploadCommands;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/config/validation.ts
|
|
490
774
|
function validatePort(port) {
|
|
491
775
|
if (port === void 0) {
|
|
492
776
|
return;
|
|
@@ -542,6 +826,7 @@ async function validateUploadOptions(options) {
|
|
|
542
826
|
}
|
|
543
827
|
assertRemotePath(options.remotePath, "remotePath");
|
|
544
828
|
validateTimeout(options.timeout);
|
|
829
|
+
getPostUploadCommands(options);
|
|
545
830
|
await assertLocalPathExists(options.localPath);
|
|
546
831
|
if (!options.dryRun) {
|
|
547
832
|
await validateServerOptions(options);
|
|
@@ -556,6 +841,9 @@ async function validateDownloadOptions(options) {
|
|
|
556
841
|
}
|
|
557
842
|
assertRemotePath(options.remotePath, "remotePath");
|
|
558
843
|
validateTimeout(options.timeout);
|
|
844
|
+
if (getPostUploadCommands(options)?.length) {
|
|
845
|
+
throw new ValidationError("Post-upload commands are not supported for downloads.");
|
|
846
|
+
}
|
|
559
847
|
await assertDownloadDestination(options.localPath, options.createDirectories ?? true);
|
|
560
848
|
if (!options.dryRun) {
|
|
561
849
|
await validateServerOptions(options);
|
|
@@ -610,10 +898,13 @@ async function ensureLocalDirectory(localPath, createParents = false) {
|
|
|
610
898
|
var ScpNextClientImpl = class {
|
|
611
899
|
serverOptions;
|
|
612
900
|
transport;
|
|
901
|
+
commandExecutor;
|
|
613
902
|
connected = false;
|
|
903
|
+
commandExecutorConnected = false;
|
|
614
904
|
constructor(serverOptions, dependencies = {}) {
|
|
615
905
|
this.serverOptions = serverOptions;
|
|
616
906
|
this.transport = dependencies.transport ?? new Ssh2SftpTransport();
|
|
907
|
+
this.commandExecutor = dependencies.commandExecutor ?? new SshCommandExecutor();
|
|
617
908
|
}
|
|
618
909
|
async connect() {
|
|
619
910
|
await validateServerOptions(this.serverOptions);
|
|
@@ -628,10 +919,15 @@ var ScpNextClientImpl = class {
|
|
|
628
919
|
};
|
|
629
920
|
await validateUploadOptions(uploadOptions);
|
|
630
921
|
if (options.dryRun) {
|
|
631
|
-
return;
|
|
922
|
+
return [];
|
|
632
923
|
}
|
|
633
924
|
await this.ensureConnected();
|
|
634
925
|
await this.uploadWithTransport(uploadOptions);
|
|
926
|
+
const results = [];
|
|
927
|
+
for (const command of getPostUploadCommands(options) ?? []) {
|
|
928
|
+
results.push(await this.exec(command));
|
|
929
|
+
}
|
|
930
|
+
return results;
|
|
635
931
|
}
|
|
636
932
|
async download(remotePath, localPath, options = {}) {
|
|
637
933
|
const downloadOptions = {
|
|
@@ -646,10 +942,57 @@ var ScpNextClientImpl = class {
|
|
|
646
942
|
await this.ensureConnected();
|
|
647
943
|
await this.downloadWithTransport(downloadOptions);
|
|
648
944
|
}
|
|
945
|
+
async exec(command, options = {}) {
|
|
946
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
947
|
+
throw new ValidationError("Remote command must not be empty.");
|
|
948
|
+
}
|
|
949
|
+
if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout <= 0)) {
|
|
950
|
+
throw new ValidationError("Command timeout must be a positive number.");
|
|
951
|
+
}
|
|
952
|
+
if (options.maxBuffer !== void 0 && (!Number.isInteger(options.maxBuffer) || options.maxBuffer <= 0)) {
|
|
953
|
+
throw new ValidationError("Command maxBuffer must be a positive integer.");
|
|
954
|
+
}
|
|
955
|
+
if (options.failOnStderr !== void 0 && typeof options.failOnStderr !== "boolean") {
|
|
956
|
+
throw new ValidationError("Command failOnStderr must be a boolean.");
|
|
957
|
+
}
|
|
958
|
+
await this.ensureCommandExecutorConnected();
|
|
959
|
+
let result;
|
|
960
|
+
try {
|
|
961
|
+
result = await this.commandExecutor.exec(command, options);
|
|
962
|
+
} catch (error) {
|
|
963
|
+
if (error instanceof RemoteCommandError) {
|
|
964
|
+
throw new RemoteCommandError(error.message, {
|
|
965
|
+
exitCode: error.exitCode,
|
|
966
|
+
signal: error.signal,
|
|
967
|
+
stdout: redactKnownSensitiveValues(error.stdout, this.serverOptions),
|
|
968
|
+
stderr: redactKnownSensitiveValues(error.stderr, this.serverOptions)
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
throw error;
|
|
972
|
+
}
|
|
973
|
+
if (result.exitCode !== 0 || options.failOnStderr && result.stderr.length > 0) {
|
|
974
|
+
const reason = result.exitCode === null ? "Remote command ended without an exit code." : result.exitCode !== 0 ? `Remote command failed with exit code ${String(result.exitCode)}.` : "Remote command wrote to stderr.";
|
|
975
|
+
throw new RemoteCommandError(reason, {
|
|
976
|
+
exitCode: result.exitCode,
|
|
977
|
+
signal: result.signal,
|
|
978
|
+
stdout: redactKnownSensitiveValues(result.stdout, this.serverOptions),
|
|
979
|
+
stderr: redactKnownSensitiveValues(result.stderr, this.serverOptions)
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
return result;
|
|
983
|
+
}
|
|
649
984
|
async close() {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
985
|
+
const closures = [];
|
|
986
|
+
if (this.connected) closures.push(this.transport.close());
|
|
987
|
+
if (this.commandExecutorConnected) closures.push(this.commandExecutor.close());
|
|
988
|
+
const results = await Promise.allSettled(closures);
|
|
989
|
+
this.connected = false;
|
|
990
|
+
this.commandExecutorConnected = false;
|
|
991
|
+
const failure = results.find(
|
|
992
|
+
(result) => result.status === "rejected"
|
|
993
|
+
);
|
|
994
|
+
if (failure) {
|
|
995
|
+
throw failure.reason;
|
|
653
996
|
}
|
|
654
997
|
}
|
|
655
998
|
async ensureConnected() {
|
|
@@ -657,6 +1000,13 @@ var ScpNextClientImpl = class {
|
|
|
657
1000
|
await this.connect();
|
|
658
1001
|
}
|
|
659
1002
|
}
|
|
1003
|
+
async ensureCommandExecutorConnected() {
|
|
1004
|
+
if (!this.commandExecutorConnected) {
|
|
1005
|
+
await validateServerOptions(this.serverOptions);
|
|
1006
|
+
await this.commandExecutor.connect(this.serverOptions);
|
|
1007
|
+
this.commandExecutorConnected = true;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
660
1010
|
async uploadWithTransport(options) {
|
|
661
1011
|
const localKind = await getLocalPathKind(options.localPath);
|
|
662
1012
|
const remotePath = normalizeRemotePath(options.remotePath);
|
|
@@ -935,11 +1285,18 @@ function createClient(options) {
|
|
|
935
1285
|
// src/client/upload.ts
|
|
936
1286
|
async function upload(options) {
|
|
937
1287
|
const client = new ScpNextClientImpl(options);
|
|
1288
|
+
let result;
|
|
938
1289
|
try {
|
|
939
|
-
await client.upload(options.localPath, options.remotePath, options);
|
|
940
|
-
}
|
|
941
|
-
|
|
1290
|
+
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
try {
|
|
1293
|
+
await client.close();
|
|
1294
|
+
} catch {
|
|
1295
|
+
}
|
|
1296
|
+
throw error;
|
|
942
1297
|
}
|
|
1298
|
+
await client.close();
|
|
1299
|
+
return result;
|
|
943
1300
|
}
|
|
944
1301
|
|
|
945
1302
|
// src/client/download.ts
|
|
@@ -983,6 +1340,7 @@ export {
|
|
|
983
1340
|
ConnectionError,
|
|
984
1341
|
FileSystemError,
|
|
985
1342
|
HostVerificationError,
|
|
1343
|
+
RemoteCommandError,
|
|
986
1344
|
ScpNextClientImpl,
|
|
987
1345
|
ScpNextError,
|
|
988
1346
|
TransferError,
|