scp-next 1.0.20 → 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 +57 -7
- 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/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.19-en.md +0 -0
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.19-zh.md +0 -0
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.2.md +0 -0
package/dist/index.cjs
CHANGED
|
@@ -35,6 +35,7 @@ __export(src_exports, {
|
|
|
35
35
|
ConnectionError: () => ConnectionError,
|
|
36
36
|
FileSystemError: () => FileSystemError,
|
|
37
37
|
HostVerificationError: () => HostVerificationError,
|
|
38
|
+
RemoteCommandError: () => RemoteCommandError,
|
|
38
39
|
ScpNextClientImpl: () => ScpNextClientImpl,
|
|
39
40
|
ScpNextError: () => ScpNextError,
|
|
40
41
|
TransferError: () => TransferError,
|
|
@@ -52,7 +53,6 @@ var import_promises6 = require("fs/promises");
|
|
|
52
53
|
var import_node_path4 = __toESM(require("path"), 1);
|
|
53
54
|
|
|
54
55
|
// src/client/transport.ts
|
|
55
|
-
var import_promises3 = require("fs/promises");
|
|
56
56
|
var import_ssh2_sftp_client = __toESM(require("ssh2-sftp-client"), 1);
|
|
57
57
|
|
|
58
58
|
// src/security/redact.ts
|
|
@@ -67,6 +67,8 @@ var SENSITIVE_KEYS = [
|
|
|
67
67
|
"secret"
|
|
68
68
|
];
|
|
69
69
|
var PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
70
|
+
var SECRET_OPTION_PATTERN = /(--(?:password|passphrase|token|secret|authorization|credential)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi;
|
|
71
|
+
var SECRET_ASSIGNMENT_PATTERN = /(\b(?:password|passphrase|token|secret|authorization|credential)[A-Z0-9_-]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s&]+)/gi;
|
|
70
72
|
function isSensitiveKey(key) {
|
|
71
73
|
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
72
74
|
return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
|
|
@@ -90,9 +92,34 @@ function redactSensitiveValues(value) {
|
|
|
90
92
|
}
|
|
91
93
|
function formatErrorMessage(error) {
|
|
92
94
|
if (error instanceof Error) {
|
|
93
|
-
return
|
|
95
|
+
return redactKnownSensitiveValues(error.message, {});
|
|
96
|
+
}
|
|
97
|
+
return redactKnownSensitiveValues(String(error), {});
|
|
98
|
+
}
|
|
99
|
+
function redactKnownSensitiveValues(text, source) {
|
|
100
|
+
const sensitiveValues = collectSensitiveValues(source);
|
|
101
|
+
const redactedKnownValues = sensitiveValues.reduce(
|
|
102
|
+
(redacted, value) => redacted.split(value).join("[REDACTED]"),
|
|
103
|
+
String(redactSensitiveValues(text))
|
|
104
|
+
);
|
|
105
|
+
return redactedKnownValues.replace(SECRET_OPTION_PATTERN, "$1[REDACTED]").replace(SECRET_ASSIGNMENT_PATTERN, "$1[REDACTED]");
|
|
106
|
+
}
|
|
107
|
+
function collectSensitiveValues(value, key = "") {
|
|
108
|
+
if (isSensitiveKey(key)) {
|
|
109
|
+
if (typeof value === "string" && value) {
|
|
110
|
+
return [value];
|
|
111
|
+
}
|
|
112
|
+
if (Buffer.isBuffer(value) && value.length > 0) {
|
|
113
|
+
return [value.toString("utf8")];
|
|
114
|
+
}
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
if (!value || typeof value !== "object") {
|
|
118
|
+
return [];
|
|
94
119
|
}
|
|
95
|
-
return
|
|
120
|
+
return Object.entries(value).flatMap(
|
|
121
|
+
([nestedKey, nestedValue]) => collectSensitiveValues(nestedValue, nestedKey)
|
|
122
|
+
);
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
// src/errors/index.ts
|
|
@@ -131,6 +158,23 @@ var TransferError = class extends ScpNextError {
|
|
|
131
158
|
super(message, { ...options, code: "SCP_NEXT_TRANSFER_ERROR" });
|
|
132
159
|
}
|
|
133
160
|
};
|
|
161
|
+
var RemoteCommandError = class extends ScpNextError {
|
|
162
|
+
exitCode;
|
|
163
|
+
signal;
|
|
164
|
+
stdout;
|
|
165
|
+
stderr;
|
|
166
|
+
constructor(message, options) {
|
|
167
|
+
super(message, {
|
|
168
|
+
code: "SCP_NEXT_REMOTE_COMMAND_ERROR",
|
|
169
|
+
...options.cause !== void 0 ? { cause: options.cause } : {},
|
|
170
|
+
...options.context !== void 0 ? { context: options.context } : {}
|
|
171
|
+
});
|
|
172
|
+
this.exitCode = options.exitCode;
|
|
173
|
+
this.signal = options.signal;
|
|
174
|
+
this.stdout = options.stdout ?? "";
|
|
175
|
+
this.stderr = options.stderr ?? "";
|
|
176
|
+
}
|
|
177
|
+
};
|
|
134
178
|
var FileSystemError = class extends ScpNextError {
|
|
135
179
|
constructor(message, options = {}) {
|
|
136
180
|
super(message, { ...options, code: "SCP_NEXT_FILESYSTEM_ERROR" });
|
|
@@ -159,6 +203,9 @@ function toScpNextError(error) {
|
|
|
159
203
|
return new TransferError(String(redactSensitiveValues(message)), { cause: error });
|
|
160
204
|
}
|
|
161
205
|
|
|
206
|
+
// src/client/ssh-options.ts
|
|
207
|
+
var import_promises3 = require("fs/promises");
|
|
208
|
+
|
|
162
209
|
// src/security/host-verification.ts
|
|
163
210
|
var import_node_crypto = require("crypto");
|
|
164
211
|
var import_promises2 = require("fs/promises");
|
|
@@ -316,6 +363,34 @@ function createHostVerifier(allowedFingerprints) {
|
|
|
316
363
|
};
|
|
317
364
|
}
|
|
318
365
|
|
|
366
|
+
// src/client/ssh-options.ts
|
|
367
|
+
async function createSshConnectOptions(options) {
|
|
368
|
+
const connectOptions = {};
|
|
369
|
+
if (options.host !== void 0) connectOptions.host = options.host;
|
|
370
|
+
if (options.port !== void 0) connectOptions.port = options.port;
|
|
371
|
+
if (options.username !== void 0) connectOptions.username = options.username;
|
|
372
|
+
if (options.password !== void 0) connectOptions.password = options.password;
|
|
373
|
+
if (options.privateKey !== void 0) {
|
|
374
|
+
connectOptions.privateKey = options.privateKey;
|
|
375
|
+
} else if (options.privateKeyFile) {
|
|
376
|
+
connectOptions.privateKey = await (0, import_promises3.readFile)(
|
|
377
|
+
expandHome(options.privateKeyFile),
|
|
378
|
+
"utf8"
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
|
|
382
|
+
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
383
|
+
if (agent !== void 0) connectOptions.agent = agent;
|
|
384
|
+
if (options.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
|
|
385
|
+
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
386
|
+
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
387
|
+
if (hostVerifier) {
|
|
388
|
+
connectOptions.hostHash = "sha256";
|
|
389
|
+
connectOptions.hostVerifier = hostVerifier;
|
|
390
|
+
}
|
|
391
|
+
return connectOptions;
|
|
392
|
+
}
|
|
393
|
+
|
|
319
394
|
// src/client/transport.ts
|
|
320
395
|
function toRemoteFileInfo(info) {
|
|
321
396
|
return {
|
|
@@ -334,28 +409,7 @@ var Ssh2SftpTransport = class {
|
|
|
334
409
|
});
|
|
335
410
|
}
|
|
336
411
|
async connect(options) {
|
|
337
|
-
const
|
|
338
|
-
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
339
|
-
const connectOptions = {};
|
|
340
|
-
if (options.host !== void 0) connectOptions.host = options.host;
|
|
341
|
-
if (options.port !== void 0) connectOptions.port = options.port;
|
|
342
|
-
if (options.username !== void 0) connectOptions.username = options.username;
|
|
343
|
-
if (options.password !== void 0) connectOptions.password = options.password;
|
|
344
|
-
if (options.privateKey !== void 0) {
|
|
345
|
-
connectOptions.privateKey = options.privateKey;
|
|
346
|
-
} else if (options.privateKeyFile) {
|
|
347
|
-
connectOptions.privateKey = await (0, import_promises3.readFile)(expandHome(options.privateKeyFile), "utf8");
|
|
348
|
-
}
|
|
349
|
-
if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
|
|
350
|
-
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
351
|
-
if (agent !== void 0) {
|
|
352
|
-
connectOptions.agent = agent;
|
|
353
|
-
}
|
|
354
|
-
if (options.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
|
|
355
|
-
if (hostVerifier) {
|
|
356
|
-
connectOptions.hostHash = "sha256";
|
|
357
|
-
connectOptions.hostVerifier = hostVerifier;
|
|
358
|
-
}
|
|
412
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
359
413
|
try {
|
|
360
414
|
await this.client.connect(connectOptions);
|
|
361
415
|
} catch (error) {
|
|
@@ -452,6 +506,210 @@ function hostVerificationTroubleshooting(host) {
|
|
|
452
506
|
].join("\n");
|
|
453
507
|
}
|
|
454
508
|
|
|
509
|
+
// src/client/command-executor.ts
|
|
510
|
+
var import_ssh2 = require("ssh2");
|
|
511
|
+
var SshCommandExecutor = class {
|
|
512
|
+
client;
|
|
513
|
+
connected = false;
|
|
514
|
+
closed = true;
|
|
515
|
+
constructor(client = new import_ssh2.Client()) {
|
|
516
|
+
this.client = client;
|
|
517
|
+
this.client.on("error", () => {
|
|
518
|
+
this.connected = false;
|
|
519
|
+
});
|
|
520
|
+
this.client.on("close", () => {
|
|
521
|
+
this.connected = false;
|
|
522
|
+
this.closed = true;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async connect(options) {
|
|
526
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
527
|
+
this.closed = false;
|
|
528
|
+
await new Promise((resolve, reject) => {
|
|
529
|
+
const onReady = () => {
|
|
530
|
+
cleanup();
|
|
531
|
+
this.connected = true;
|
|
532
|
+
resolve();
|
|
533
|
+
};
|
|
534
|
+
const onError = (error) => {
|
|
535
|
+
cleanup();
|
|
536
|
+
this.connected = false;
|
|
537
|
+
this.closed = true;
|
|
538
|
+
try {
|
|
539
|
+
this.client.end();
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
const converted = toScpNextError(error);
|
|
543
|
+
if (converted instanceof HostVerificationError) {
|
|
544
|
+
reject(converted);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
reject(
|
|
548
|
+
new ConnectionError(
|
|
549
|
+
`Unable to connect command executor to SSH server: ${formatErrorMessage(error)}`,
|
|
550
|
+
{
|
|
551
|
+
cause: error,
|
|
552
|
+
context: {
|
|
553
|
+
host: options.host,
|
|
554
|
+
port: options.port,
|
|
555
|
+
username: options.username
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
)
|
|
559
|
+
);
|
|
560
|
+
};
|
|
561
|
+
const onClose = () => {
|
|
562
|
+
cleanup();
|
|
563
|
+
reject(new ConnectionError("SSH connection closed before it was ready."));
|
|
564
|
+
};
|
|
565
|
+
const cleanup = () => {
|
|
566
|
+
this.client.off("ready", onReady);
|
|
567
|
+
this.client.off("error", onError);
|
|
568
|
+
this.client.off("close", onClose);
|
|
569
|
+
};
|
|
570
|
+
this.client.once("ready", onReady);
|
|
571
|
+
this.client.once("error", onError);
|
|
572
|
+
this.client.once("close", onClose);
|
|
573
|
+
try {
|
|
574
|
+
this.client.connect(connectOptions);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
cleanup();
|
|
577
|
+
this.closed = true;
|
|
578
|
+
reject(
|
|
579
|
+
new ConnectionError(
|
|
580
|
+
`Unable to start SSH connection: ${formatErrorMessage(error)}`
|
|
581
|
+
)
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
async exec(command, options = {}) {
|
|
587
|
+
if (!this.connected) {
|
|
588
|
+
throw new ConnectionError("Command executor is not connected.");
|
|
589
|
+
}
|
|
590
|
+
return new Promise((resolve, reject) => {
|
|
591
|
+
try {
|
|
592
|
+
this.client.exec(command, (error, stream) => {
|
|
593
|
+
if (error) {
|
|
594
|
+
reject(
|
|
595
|
+
new RemoteCommandError("Unable to start remote command.", {
|
|
596
|
+
exitCode: null
|
|
597
|
+
})
|
|
598
|
+
);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
collectCommandResult(command, stream, options, resolve, reject);
|
|
602
|
+
});
|
|
603
|
+
} catch (error) {
|
|
604
|
+
reject(
|
|
605
|
+
new ConnectionError(
|
|
606
|
+
`Unable to start remote command: ${formatErrorMessage(error)}`
|
|
607
|
+
)
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
async close() {
|
|
613
|
+
if (this.closed) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
this.connected = false;
|
|
617
|
+
await new Promise((resolve) => {
|
|
618
|
+
this.client.once("close", resolve);
|
|
619
|
+
this.client.end();
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
function collectCommandResult(command, stream, options, resolve, reject) {
|
|
624
|
+
let stdout = "";
|
|
625
|
+
let stderr = "";
|
|
626
|
+
let outputBytes = 0;
|
|
627
|
+
let settled = false;
|
|
628
|
+
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024;
|
|
629
|
+
const cleanup = () => {
|
|
630
|
+
if (timer) clearTimeout(timer);
|
|
631
|
+
stream.off("data", onStdoutData);
|
|
632
|
+
stream.stderr.off("data", onStderrData);
|
|
633
|
+
stream.off("close", onClose);
|
|
634
|
+
stream.off("error", onStreamError);
|
|
635
|
+
stream.stderr.off("error", onStreamError);
|
|
636
|
+
};
|
|
637
|
+
const fail = (error) => {
|
|
638
|
+
if (settled) return;
|
|
639
|
+
settled = true;
|
|
640
|
+
cleanup();
|
|
641
|
+
try {
|
|
642
|
+
stream.close();
|
|
643
|
+
} catch {
|
|
644
|
+
}
|
|
645
|
+
reject(error);
|
|
646
|
+
};
|
|
647
|
+
const appendOutput = (target, chunk) => {
|
|
648
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
649
|
+
if (outputBytes > maxBuffer) {
|
|
650
|
+
fail(
|
|
651
|
+
new RemoteCommandError(
|
|
652
|
+
`Remote command output exceeded maxBuffer of ${maxBuffer} bytes.`,
|
|
653
|
+
{
|
|
654
|
+
exitCode: null,
|
|
655
|
+
stdout,
|
|
656
|
+
stderr
|
|
657
|
+
}
|
|
658
|
+
)
|
|
659
|
+
);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (target === "stdout") stdout += chunk;
|
|
663
|
+
else stderr += chunk;
|
|
664
|
+
};
|
|
665
|
+
const onStreamError = () => {
|
|
666
|
+
fail(
|
|
667
|
+
new RemoteCommandError("Remote command stream failed.", {
|
|
668
|
+
exitCode: null,
|
|
669
|
+
stdout,
|
|
670
|
+
stderr
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
};
|
|
674
|
+
const onStdoutData = (chunk) => {
|
|
675
|
+
appendOutput("stdout", chunk);
|
|
676
|
+
};
|
|
677
|
+
const onStderrData = (chunk) => {
|
|
678
|
+
appendOutput("stderr", chunk);
|
|
679
|
+
};
|
|
680
|
+
const onClose = (exitCode, signal) => {
|
|
681
|
+
if (settled) return;
|
|
682
|
+
settled = true;
|
|
683
|
+
cleanup();
|
|
684
|
+
resolve({
|
|
685
|
+
command,
|
|
686
|
+
stdout,
|
|
687
|
+
stderr,
|
|
688
|
+
exitCode: typeof exitCode === "number" ? exitCode : null,
|
|
689
|
+
...signal ? { signal } : {}
|
|
690
|
+
});
|
|
691
|
+
};
|
|
692
|
+
const timer = options.timeout === void 0 ? void 0 : setTimeout(() => {
|
|
693
|
+
fail(
|
|
694
|
+
new RemoteCommandError(
|
|
695
|
+
`Remote command timed out after ${options.timeout} ms.`,
|
|
696
|
+
{
|
|
697
|
+
exitCode: null,
|
|
698
|
+
stdout,
|
|
699
|
+
stderr
|
|
700
|
+
}
|
|
701
|
+
)
|
|
702
|
+
);
|
|
703
|
+
}, options.timeout);
|
|
704
|
+
stream.setEncoding("utf8");
|
|
705
|
+
stream.stderr.setEncoding("utf8");
|
|
706
|
+
stream.on("data", onStdoutData);
|
|
707
|
+
stream.stderr.on("data", onStderrData);
|
|
708
|
+
stream.once("error", onStreamError);
|
|
709
|
+
stream.stderr.once("error", onStreamError);
|
|
710
|
+
stream.once("close", onClose);
|
|
711
|
+
}
|
|
712
|
+
|
|
455
713
|
// src/client/walk.ts
|
|
456
714
|
var import_promises4 = require("fs/promises");
|
|
457
715
|
var import_node_path3 = __toESM(require("path"), 1);
|
|
@@ -536,6 +794,33 @@ async function walkRemoteFiles(transport, rootPath) {
|
|
|
536
794
|
|
|
537
795
|
// src/config/validation.ts
|
|
538
796
|
var import_promises5 = require("fs/promises");
|
|
797
|
+
|
|
798
|
+
// src/config/post-upload-commands.ts
|
|
799
|
+
function isPostUploadCommands(value) {
|
|
800
|
+
return Array.isArray(value) && value.every(
|
|
801
|
+
(command) => typeof command === "string" && command.trim().length > 0
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
function getPostUploadCommands(source) {
|
|
805
|
+
const commands = readPostUploadCommands(source);
|
|
806
|
+
if (commands === void 0) {
|
|
807
|
+
return void 0;
|
|
808
|
+
}
|
|
809
|
+
if (!isPostUploadCommands(commands)) {
|
|
810
|
+
throw new ValidationError(
|
|
811
|
+
"postUploadCommands must be an array of non-empty command strings."
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
return commands;
|
|
815
|
+
}
|
|
816
|
+
function readPostUploadCommands(source) {
|
|
817
|
+
if (typeof source !== "object" || source === null) {
|
|
818
|
+
return void 0;
|
|
819
|
+
}
|
|
820
|
+
return source.postUploadCommands;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/config/validation.ts
|
|
539
824
|
function validatePort(port) {
|
|
540
825
|
if (port === void 0) {
|
|
541
826
|
return;
|
|
@@ -591,6 +876,7 @@ async function validateUploadOptions(options) {
|
|
|
591
876
|
}
|
|
592
877
|
assertRemotePath(options.remotePath, "remotePath");
|
|
593
878
|
validateTimeout(options.timeout);
|
|
879
|
+
getPostUploadCommands(options);
|
|
594
880
|
await assertLocalPathExists(options.localPath);
|
|
595
881
|
if (!options.dryRun) {
|
|
596
882
|
await validateServerOptions(options);
|
|
@@ -605,6 +891,9 @@ async function validateDownloadOptions(options) {
|
|
|
605
891
|
}
|
|
606
892
|
assertRemotePath(options.remotePath, "remotePath");
|
|
607
893
|
validateTimeout(options.timeout);
|
|
894
|
+
if (getPostUploadCommands(options)?.length) {
|
|
895
|
+
throw new ValidationError("Post-upload commands are not supported for downloads.");
|
|
896
|
+
}
|
|
608
897
|
await assertDownloadDestination(options.localPath, options.createDirectories ?? true);
|
|
609
898
|
if (!options.dryRun) {
|
|
610
899
|
await validateServerOptions(options);
|
|
@@ -659,10 +948,13 @@ async function ensureLocalDirectory(localPath, createParents = false) {
|
|
|
659
948
|
var ScpNextClientImpl = class {
|
|
660
949
|
serverOptions;
|
|
661
950
|
transport;
|
|
951
|
+
commandExecutor;
|
|
662
952
|
connected = false;
|
|
953
|
+
commandExecutorConnected = false;
|
|
663
954
|
constructor(serverOptions, dependencies = {}) {
|
|
664
955
|
this.serverOptions = serverOptions;
|
|
665
956
|
this.transport = dependencies.transport ?? new Ssh2SftpTransport();
|
|
957
|
+
this.commandExecutor = dependencies.commandExecutor ?? new SshCommandExecutor();
|
|
666
958
|
}
|
|
667
959
|
async connect() {
|
|
668
960
|
await validateServerOptions(this.serverOptions);
|
|
@@ -677,10 +969,15 @@ var ScpNextClientImpl = class {
|
|
|
677
969
|
};
|
|
678
970
|
await validateUploadOptions(uploadOptions);
|
|
679
971
|
if (options.dryRun) {
|
|
680
|
-
return;
|
|
972
|
+
return [];
|
|
681
973
|
}
|
|
682
974
|
await this.ensureConnected();
|
|
683
975
|
await this.uploadWithTransport(uploadOptions);
|
|
976
|
+
const results = [];
|
|
977
|
+
for (const command of getPostUploadCommands(options) ?? []) {
|
|
978
|
+
results.push(await this.exec(command));
|
|
979
|
+
}
|
|
980
|
+
return results;
|
|
684
981
|
}
|
|
685
982
|
async download(remotePath, localPath, options = {}) {
|
|
686
983
|
const downloadOptions = {
|
|
@@ -695,10 +992,57 @@ var ScpNextClientImpl = class {
|
|
|
695
992
|
await this.ensureConnected();
|
|
696
993
|
await this.downloadWithTransport(downloadOptions);
|
|
697
994
|
}
|
|
995
|
+
async exec(command, options = {}) {
|
|
996
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
997
|
+
throw new ValidationError("Remote command must not be empty.");
|
|
998
|
+
}
|
|
999
|
+
if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout <= 0)) {
|
|
1000
|
+
throw new ValidationError("Command timeout must be a positive number.");
|
|
1001
|
+
}
|
|
1002
|
+
if (options.maxBuffer !== void 0 && (!Number.isInteger(options.maxBuffer) || options.maxBuffer <= 0)) {
|
|
1003
|
+
throw new ValidationError("Command maxBuffer must be a positive integer.");
|
|
1004
|
+
}
|
|
1005
|
+
if (options.failOnStderr !== void 0 && typeof options.failOnStderr !== "boolean") {
|
|
1006
|
+
throw new ValidationError("Command failOnStderr must be a boolean.");
|
|
1007
|
+
}
|
|
1008
|
+
await this.ensureCommandExecutorConnected();
|
|
1009
|
+
let result;
|
|
1010
|
+
try {
|
|
1011
|
+
result = await this.commandExecutor.exec(command, options);
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
if (error instanceof RemoteCommandError) {
|
|
1014
|
+
throw new RemoteCommandError(error.message, {
|
|
1015
|
+
exitCode: error.exitCode,
|
|
1016
|
+
signal: error.signal,
|
|
1017
|
+
stdout: redactKnownSensitiveValues(error.stdout, this.serverOptions),
|
|
1018
|
+
stderr: redactKnownSensitiveValues(error.stderr, this.serverOptions)
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
throw error;
|
|
1022
|
+
}
|
|
1023
|
+
if (result.exitCode !== 0 || options.failOnStderr && result.stderr.length > 0) {
|
|
1024
|
+
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.";
|
|
1025
|
+
throw new RemoteCommandError(reason, {
|
|
1026
|
+
exitCode: result.exitCode,
|
|
1027
|
+
signal: result.signal,
|
|
1028
|
+
stdout: redactKnownSensitiveValues(result.stdout, this.serverOptions),
|
|
1029
|
+
stderr: redactKnownSensitiveValues(result.stderr, this.serverOptions)
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
return result;
|
|
1033
|
+
}
|
|
698
1034
|
async close() {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1035
|
+
const closures = [];
|
|
1036
|
+
if (this.connected) closures.push(this.transport.close());
|
|
1037
|
+
if (this.commandExecutorConnected) closures.push(this.commandExecutor.close());
|
|
1038
|
+
const results = await Promise.allSettled(closures);
|
|
1039
|
+
this.connected = false;
|
|
1040
|
+
this.commandExecutorConnected = false;
|
|
1041
|
+
const failure = results.find(
|
|
1042
|
+
(result) => result.status === "rejected"
|
|
1043
|
+
);
|
|
1044
|
+
if (failure) {
|
|
1045
|
+
throw failure.reason;
|
|
702
1046
|
}
|
|
703
1047
|
}
|
|
704
1048
|
async ensureConnected() {
|
|
@@ -706,6 +1050,13 @@ var ScpNextClientImpl = class {
|
|
|
706
1050
|
await this.connect();
|
|
707
1051
|
}
|
|
708
1052
|
}
|
|
1053
|
+
async ensureCommandExecutorConnected() {
|
|
1054
|
+
if (!this.commandExecutorConnected) {
|
|
1055
|
+
await validateServerOptions(this.serverOptions);
|
|
1056
|
+
await this.commandExecutor.connect(this.serverOptions);
|
|
1057
|
+
this.commandExecutorConnected = true;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
709
1060
|
async uploadWithTransport(options) {
|
|
710
1061
|
const localKind = await getLocalPathKind(options.localPath);
|
|
711
1062
|
const remotePath = normalizeRemotePath(options.remotePath);
|
|
@@ -984,11 +1335,18 @@ function createClient(options) {
|
|
|
984
1335
|
// src/client/upload.ts
|
|
985
1336
|
async function upload(options) {
|
|
986
1337
|
const client = new ScpNextClientImpl(options);
|
|
1338
|
+
let result;
|
|
987
1339
|
try {
|
|
988
|
-
await client.upload(options.localPath, options.remotePath, options);
|
|
989
|
-
}
|
|
990
|
-
|
|
1340
|
+
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
try {
|
|
1343
|
+
await client.close();
|
|
1344
|
+
} catch {
|
|
1345
|
+
}
|
|
1346
|
+
throw error;
|
|
991
1347
|
}
|
|
1348
|
+
await client.close();
|
|
1349
|
+
return result;
|
|
992
1350
|
}
|
|
993
1351
|
|
|
994
1352
|
// src/client/download.ts
|
|
@@ -1033,6 +1391,7 @@ async function copy(options) {
|
|
|
1033
1391
|
ConnectionError,
|
|
1034
1392
|
FileSystemError,
|
|
1035
1393
|
HostVerificationError,
|
|
1394
|
+
RemoteCommandError,
|
|
1036
1395
|
ScpNextClientImpl,
|
|
1037
1396
|
ScpNextError,
|
|
1038
1397
|
TransferError,
|