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/cli/index.js
CHANGED
|
@@ -11,7 +11,6 @@ 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
|
|
14
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
15
14
|
import SftpClient from "ssh2-sftp-client";
|
|
16
15
|
|
|
17
16
|
// src/security/redact.ts
|
|
@@ -26,6 +25,8 @@ var SENSITIVE_KEYS = [
|
|
|
26
25
|
"secret"
|
|
27
26
|
];
|
|
28
27
|
var PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
28
|
+
var SECRET_OPTION_PATTERN = /(--(?:password|passphrase|token|secret|authorization|credential)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi;
|
|
29
|
+
var SECRET_ASSIGNMENT_PATTERN = /(\b(?:password|passphrase|token|secret|authorization|credential)[A-Z0-9_-]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s&]+)/gi;
|
|
29
30
|
function isSensitiveKey(key) {
|
|
30
31
|
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
31
32
|
return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
|
|
@@ -49,9 +50,34 @@ function redactSensitiveValues(value) {
|
|
|
49
50
|
}
|
|
50
51
|
function formatErrorMessage(error) {
|
|
51
52
|
if (error instanceof Error) {
|
|
52
|
-
return
|
|
53
|
+
return redactKnownSensitiveValues(error.message, {});
|
|
54
|
+
}
|
|
55
|
+
return redactKnownSensitiveValues(String(error), {});
|
|
56
|
+
}
|
|
57
|
+
function redactKnownSensitiveValues(text, source) {
|
|
58
|
+
const sensitiveValues = collectSensitiveValues(source);
|
|
59
|
+
const redactedKnownValues = sensitiveValues.reduce(
|
|
60
|
+
(redacted, value) => redacted.split(value).join("[REDACTED]"),
|
|
61
|
+
String(redactSensitiveValues(text))
|
|
62
|
+
);
|
|
63
|
+
return redactedKnownValues.replace(SECRET_OPTION_PATTERN, "$1[REDACTED]").replace(SECRET_ASSIGNMENT_PATTERN, "$1[REDACTED]");
|
|
64
|
+
}
|
|
65
|
+
function collectSensitiveValues(value, key = "") {
|
|
66
|
+
if (isSensitiveKey(key)) {
|
|
67
|
+
if (typeof value === "string" && value) {
|
|
68
|
+
return [value];
|
|
69
|
+
}
|
|
70
|
+
if (Buffer.isBuffer(value) && value.length > 0) {
|
|
71
|
+
return [value.toString("utf8")];
|
|
72
|
+
}
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
if (!value || typeof value !== "object") {
|
|
76
|
+
return [];
|
|
53
77
|
}
|
|
54
|
-
return
|
|
78
|
+
return Object.entries(value).flatMap(
|
|
79
|
+
([nestedKey, nestedValue]) => collectSensitiveValues(nestedValue, nestedKey)
|
|
80
|
+
);
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
// src/errors/index.ts
|
|
@@ -90,6 +116,23 @@ var TransferError = class extends ScpNextError {
|
|
|
90
116
|
super(message, { ...options, code: "SCP_NEXT_TRANSFER_ERROR" });
|
|
91
117
|
}
|
|
92
118
|
};
|
|
119
|
+
var RemoteCommandError = class extends ScpNextError {
|
|
120
|
+
exitCode;
|
|
121
|
+
signal;
|
|
122
|
+
stdout;
|
|
123
|
+
stderr;
|
|
124
|
+
constructor(message, options) {
|
|
125
|
+
super(message, {
|
|
126
|
+
code: "SCP_NEXT_REMOTE_COMMAND_ERROR",
|
|
127
|
+
...options.cause !== void 0 ? { cause: options.cause } : {},
|
|
128
|
+
...options.context !== void 0 ? { context: options.context } : {}
|
|
129
|
+
});
|
|
130
|
+
this.exitCode = options.exitCode;
|
|
131
|
+
this.signal = options.signal;
|
|
132
|
+
this.stdout = options.stdout ?? "";
|
|
133
|
+
this.stderr = options.stderr ?? "";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
93
136
|
var FileSystemError = class extends ScpNextError {
|
|
94
137
|
constructor(message, options = {}) {
|
|
95
138
|
super(message, { ...options, code: "SCP_NEXT_FILESYSTEM_ERROR" });
|
|
@@ -118,6 +161,9 @@ function toScpNextError(error) {
|
|
|
118
161
|
return new TransferError(String(redactSensitiveValues(message)), { cause: error });
|
|
119
162
|
}
|
|
120
163
|
|
|
164
|
+
// src/client/ssh-options.ts
|
|
165
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
166
|
+
|
|
121
167
|
// src/security/host-verification.ts
|
|
122
168
|
import { createHash } from "crypto";
|
|
123
169
|
import { access as access2, readFile } from "fs/promises";
|
|
@@ -275,6 +321,34 @@ function createHostVerifier(allowedFingerprints) {
|
|
|
275
321
|
};
|
|
276
322
|
}
|
|
277
323
|
|
|
324
|
+
// src/client/ssh-options.ts
|
|
325
|
+
async function createSshConnectOptions(options) {
|
|
326
|
+
const connectOptions = {};
|
|
327
|
+
if (options.host !== void 0) connectOptions.host = options.host;
|
|
328
|
+
if (options.port !== void 0) connectOptions.port = options.port;
|
|
329
|
+
if (options.username !== void 0) connectOptions.username = options.username;
|
|
330
|
+
if (options.password !== void 0) connectOptions.password = options.password;
|
|
331
|
+
if (options.privateKey !== void 0) {
|
|
332
|
+
connectOptions.privateKey = options.privateKey;
|
|
333
|
+
} else if (options.privateKeyFile) {
|
|
334
|
+
connectOptions.privateKey = await readFile2(
|
|
335
|
+
expandHome(options.privateKeyFile),
|
|
336
|
+
"utf8"
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
|
|
340
|
+
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
341
|
+
if (agent !== void 0) connectOptions.agent = agent;
|
|
342
|
+
if (options.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
|
|
343
|
+
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
344
|
+
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
345
|
+
if (hostVerifier) {
|
|
346
|
+
connectOptions.hostHash = "sha256";
|
|
347
|
+
connectOptions.hostVerifier = hostVerifier;
|
|
348
|
+
}
|
|
349
|
+
return connectOptions;
|
|
350
|
+
}
|
|
351
|
+
|
|
278
352
|
// src/client/transport.ts
|
|
279
353
|
function toRemoteFileInfo(info) {
|
|
280
354
|
return {
|
|
@@ -293,28 +367,7 @@ var Ssh2SftpTransport = class {
|
|
|
293
367
|
});
|
|
294
368
|
}
|
|
295
369
|
async connect(options) {
|
|
296
|
-
const
|
|
297
|
-
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
298
|
-
const connectOptions = {};
|
|
299
|
-
if (options.host !== void 0) connectOptions.host = options.host;
|
|
300
|
-
if (options.port !== void 0) connectOptions.port = options.port;
|
|
301
|
-
if (options.username !== void 0) connectOptions.username = options.username;
|
|
302
|
-
if (options.password !== void 0) connectOptions.password = options.password;
|
|
303
|
-
if (options.privateKey !== void 0) {
|
|
304
|
-
connectOptions.privateKey = options.privateKey;
|
|
305
|
-
} else if (options.privateKeyFile) {
|
|
306
|
-
connectOptions.privateKey = await readFile2(expandHome(options.privateKeyFile), "utf8");
|
|
307
|
-
}
|
|
308
|
-
if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
|
|
309
|
-
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
310
|
-
if (agent !== void 0) {
|
|
311
|
-
connectOptions.agent = agent;
|
|
312
|
-
}
|
|
313
|
-
if (options.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
|
|
314
|
-
if (hostVerifier) {
|
|
315
|
-
connectOptions.hostHash = "sha256";
|
|
316
|
-
connectOptions.hostVerifier = hostVerifier;
|
|
317
|
-
}
|
|
370
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
318
371
|
try {
|
|
319
372
|
await this.client.connect(connectOptions);
|
|
320
373
|
} catch (error) {
|
|
@@ -411,6 +464,210 @@ function hostVerificationTroubleshooting(host) {
|
|
|
411
464
|
].join("\n");
|
|
412
465
|
}
|
|
413
466
|
|
|
467
|
+
// src/client/command-executor.ts
|
|
468
|
+
import { Client } from "ssh2";
|
|
469
|
+
var SshCommandExecutor = class {
|
|
470
|
+
client;
|
|
471
|
+
connected = false;
|
|
472
|
+
closed = true;
|
|
473
|
+
constructor(client = new Client()) {
|
|
474
|
+
this.client = client;
|
|
475
|
+
this.client.on("error", () => {
|
|
476
|
+
this.connected = false;
|
|
477
|
+
});
|
|
478
|
+
this.client.on("close", () => {
|
|
479
|
+
this.connected = false;
|
|
480
|
+
this.closed = true;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
async connect(options) {
|
|
484
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
485
|
+
this.closed = false;
|
|
486
|
+
await new Promise((resolve2, reject) => {
|
|
487
|
+
const onReady = () => {
|
|
488
|
+
cleanup();
|
|
489
|
+
this.connected = true;
|
|
490
|
+
resolve2();
|
|
491
|
+
};
|
|
492
|
+
const onError = (error) => {
|
|
493
|
+
cleanup();
|
|
494
|
+
this.connected = false;
|
|
495
|
+
this.closed = true;
|
|
496
|
+
try {
|
|
497
|
+
this.client.end();
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
const converted = toScpNextError(error);
|
|
501
|
+
if (converted instanceof HostVerificationError) {
|
|
502
|
+
reject(converted);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
reject(
|
|
506
|
+
new ConnectionError(
|
|
507
|
+
`Unable to connect command executor to SSH server: ${formatErrorMessage(error)}`,
|
|
508
|
+
{
|
|
509
|
+
cause: error,
|
|
510
|
+
context: {
|
|
511
|
+
host: options.host,
|
|
512
|
+
port: options.port,
|
|
513
|
+
username: options.username
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
};
|
|
519
|
+
const onClose = () => {
|
|
520
|
+
cleanup();
|
|
521
|
+
reject(new ConnectionError("SSH connection closed before it was ready."));
|
|
522
|
+
};
|
|
523
|
+
const cleanup = () => {
|
|
524
|
+
this.client.off("ready", onReady);
|
|
525
|
+
this.client.off("error", onError);
|
|
526
|
+
this.client.off("close", onClose);
|
|
527
|
+
};
|
|
528
|
+
this.client.once("ready", onReady);
|
|
529
|
+
this.client.once("error", onError);
|
|
530
|
+
this.client.once("close", onClose);
|
|
531
|
+
try {
|
|
532
|
+
this.client.connect(connectOptions);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
cleanup();
|
|
535
|
+
this.closed = true;
|
|
536
|
+
reject(
|
|
537
|
+
new ConnectionError(
|
|
538
|
+
`Unable to start SSH connection: ${formatErrorMessage(error)}`
|
|
539
|
+
)
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
async exec(command, options = {}) {
|
|
545
|
+
if (!this.connected) {
|
|
546
|
+
throw new ConnectionError("Command executor is not connected.");
|
|
547
|
+
}
|
|
548
|
+
return new Promise((resolve2, reject) => {
|
|
549
|
+
try {
|
|
550
|
+
this.client.exec(command, (error, stream) => {
|
|
551
|
+
if (error) {
|
|
552
|
+
reject(
|
|
553
|
+
new RemoteCommandError("Unable to start remote command.", {
|
|
554
|
+
exitCode: null
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
collectCommandResult(command, stream, options, resolve2, reject);
|
|
560
|
+
});
|
|
561
|
+
} catch (error) {
|
|
562
|
+
reject(
|
|
563
|
+
new ConnectionError(
|
|
564
|
+
`Unable to start remote command: ${formatErrorMessage(error)}`
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async close() {
|
|
571
|
+
if (this.closed) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
this.connected = false;
|
|
575
|
+
await new Promise((resolve2) => {
|
|
576
|
+
this.client.once("close", resolve2);
|
|
577
|
+
this.client.end();
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
function collectCommandResult(command, stream, options, resolve2, reject) {
|
|
582
|
+
let stdout = "";
|
|
583
|
+
let stderr = "";
|
|
584
|
+
let outputBytes = 0;
|
|
585
|
+
let settled = false;
|
|
586
|
+
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024;
|
|
587
|
+
const cleanup = () => {
|
|
588
|
+
if (timer) clearTimeout(timer);
|
|
589
|
+
stream.off("data", onStdoutData);
|
|
590
|
+
stream.stderr.off("data", onStderrData);
|
|
591
|
+
stream.off("close", onClose);
|
|
592
|
+
stream.off("error", onStreamError);
|
|
593
|
+
stream.stderr.off("error", onStreamError);
|
|
594
|
+
};
|
|
595
|
+
const fail = (error) => {
|
|
596
|
+
if (settled) return;
|
|
597
|
+
settled = true;
|
|
598
|
+
cleanup();
|
|
599
|
+
try {
|
|
600
|
+
stream.close();
|
|
601
|
+
} catch {
|
|
602
|
+
}
|
|
603
|
+
reject(error);
|
|
604
|
+
};
|
|
605
|
+
const appendOutput = (target, chunk) => {
|
|
606
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
607
|
+
if (outputBytes > maxBuffer) {
|
|
608
|
+
fail(
|
|
609
|
+
new RemoteCommandError(
|
|
610
|
+
`Remote command output exceeded maxBuffer of ${maxBuffer} bytes.`,
|
|
611
|
+
{
|
|
612
|
+
exitCode: null,
|
|
613
|
+
stdout,
|
|
614
|
+
stderr
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (target === "stdout") stdout += chunk;
|
|
621
|
+
else stderr += chunk;
|
|
622
|
+
};
|
|
623
|
+
const onStreamError = () => {
|
|
624
|
+
fail(
|
|
625
|
+
new RemoteCommandError("Remote command stream failed.", {
|
|
626
|
+
exitCode: null,
|
|
627
|
+
stdout,
|
|
628
|
+
stderr
|
|
629
|
+
})
|
|
630
|
+
);
|
|
631
|
+
};
|
|
632
|
+
const onStdoutData = (chunk) => {
|
|
633
|
+
appendOutput("stdout", chunk);
|
|
634
|
+
};
|
|
635
|
+
const onStderrData = (chunk) => {
|
|
636
|
+
appendOutput("stderr", chunk);
|
|
637
|
+
};
|
|
638
|
+
const onClose = (exitCode, signal) => {
|
|
639
|
+
if (settled) return;
|
|
640
|
+
settled = true;
|
|
641
|
+
cleanup();
|
|
642
|
+
resolve2({
|
|
643
|
+
command,
|
|
644
|
+
stdout,
|
|
645
|
+
stderr,
|
|
646
|
+
exitCode: typeof exitCode === "number" ? exitCode : null,
|
|
647
|
+
...signal ? { signal } : {}
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
const timer = options.timeout === void 0 ? void 0 : setTimeout(() => {
|
|
651
|
+
fail(
|
|
652
|
+
new RemoteCommandError(
|
|
653
|
+
`Remote command timed out after ${options.timeout} ms.`,
|
|
654
|
+
{
|
|
655
|
+
exitCode: null,
|
|
656
|
+
stdout,
|
|
657
|
+
stderr
|
|
658
|
+
}
|
|
659
|
+
)
|
|
660
|
+
);
|
|
661
|
+
}, options.timeout);
|
|
662
|
+
stream.setEncoding("utf8");
|
|
663
|
+
stream.stderr.setEncoding("utf8");
|
|
664
|
+
stream.on("data", onStdoutData);
|
|
665
|
+
stream.stderr.on("data", onStderrData);
|
|
666
|
+
stream.once("error", onStreamError);
|
|
667
|
+
stream.stderr.once("error", onStreamError);
|
|
668
|
+
stream.once("close", onClose);
|
|
669
|
+
}
|
|
670
|
+
|
|
414
671
|
// src/client/walk.ts
|
|
415
672
|
import { readdir, stat as stat2 } from "fs/promises";
|
|
416
673
|
import path3 from "path";
|
|
@@ -502,6 +759,36 @@ async function walkRemoteFiles(transport, rootPath) {
|
|
|
502
759
|
|
|
503
760
|
// src/config/validation.ts
|
|
504
761
|
import { access as access3 } from "fs/promises";
|
|
762
|
+
|
|
763
|
+
// src/config/post-upload-commands.ts
|
|
764
|
+
function isPostUploadCommands(value) {
|
|
765
|
+
return Array.isArray(value) && value.every(
|
|
766
|
+
(command) => typeof command === "string" && command.trim().length > 0
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
function getPostUploadCommands(source) {
|
|
770
|
+
const commands = readPostUploadCommands(source);
|
|
771
|
+
if (commands === void 0) {
|
|
772
|
+
return void 0;
|
|
773
|
+
}
|
|
774
|
+
if (!isPostUploadCommands(commands)) {
|
|
775
|
+
throw new ValidationError(
|
|
776
|
+
"postUploadCommands must be an array of non-empty command strings."
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return commands;
|
|
780
|
+
}
|
|
781
|
+
function readPostUploadCommands(source) {
|
|
782
|
+
if (typeof source !== "object" || source === null) {
|
|
783
|
+
return void 0;
|
|
784
|
+
}
|
|
785
|
+
return source.postUploadCommands;
|
|
786
|
+
}
|
|
787
|
+
function clearPostUploadCommands(source) {
|
|
788
|
+
source.postUploadCommands = void 0;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/config/validation.ts
|
|
505
792
|
function validatePort(port) {
|
|
506
793
|
if (port === void 0) {
|
|
507
794
|
return;
|
|
@@ -557,6 +844,7 @@ async function validateUploadOptions(options) {
|
|
|
557
844
|
}
|
|
558
845
|
assertRemotePath(options.remotePath, "remotePath");
|
|
559
846
|
validateTimeout(options.timeout);
|
|
847
|
+
getPostUploadCommands(options);
|
|
560
848
|
await assertLocalPathExists(options.localPath);
|
|
561
849
|
if (!options.dryRun) {
|
|
562
850
|
await validateServerOptions(options);
|
|
@@ -571,6 +859,9 @@ async function validateDownloadOptions(options) {
|
|
|
571
859
|
}
|
|
572
860
|
assertRemotePath(options.remotePath, "remotePath");
|
|
573
861
|
validateTimeout(options.timeout);
|
|
862
|
+
if (getPostUploadCommands(options)?.length) {
|
|
863
|
+
throw new ValidationError("Post-upload commands are not supported for downloads.");
|
|
864
|
+
}
|
|
574
865
|
await assertDownloadDestination(options.localPath, options.createDirectories ?? true);
|
|
575
866
|
if (!options.dryRun) {
|
|
576
867
|
await validateServerOptions(options);
|
|
@@ -625,10 +916,13 @@ async function ensureLocalDirectory(localPath, createParents = false) {
|
|
|
625
916
|
var ScpNextClientImpl = class {
|
|
626
917
|
serverOptions;
|
|
627
918
|
transport;
|
|
919
|
+
commandExecutor;
|
|
628
920
|
connected = false;
|
|
921
|
+
commandExecutorConnected = false;
|
|
629
922
|
constructor(serverOptions, dependencies = {}) {
|
|
630
923
|
this.serverOptions = serverOptions;
|
|
631
924
|
this.transport = dependencies.transport ?? new Ssh2SftpTransport();
|
|
925
|
+
this.commandExecutor = dependencies.commandExecutor ?? new SshCommandExecutor();
|
|
632
926
|
}
|
|
633
927
|
async connect() {
|
|
634
928
|
await validateServerOptions(this.serverOptions);
|
|
@@ -643,10 +937,15 @@ var ScpNextClientImpl = class {
|
|
|
643
937
|
};
|
|
644
938
|
await validateUploadOptions(uploadOptions);
|
|
645
939
|
if (options.dryRun) {
|
|
646
|
-
return;
|
|
940
|
+
return [];
|
|
647
941
|
}
|
|
648
942
|
await this.ensureConnected();
|
|
649
943
|
await this.uploadWithTransport(uploadOptions);
|
|
944
|
+
const results = [];
|
|
945
|
+
for (const command of getPostUploadCommands(options) ?? []) {
|
|
946
|
+
results.push(await this.exec(command));
|
|
947
|
+
}
|
|
948
|
+
return results;
|
|
650
949
|
}
|
|
651
950
|
async download(remotePath, localPath, options = {}) {
|
|
652
951
|
const downloadOptions = {
|
|
@@ -661,10 +960,57 @@ var ScpNextClientImpl = class {
|
|
|
661
960
|
await this.ensureConnected();
|
|
662
961
|
await this.downloadWithTransport(downloadOptions);
|
|
663
962
|
}
|
|
963
|
+
async exec(command, options = {}) {
|
|
964
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
965
|
+
throw new ValidationError("Remote command must not be empty.");
|
|
966
|
+
}
|
|
967
|
+
if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout <= 0)) {
|
|
968
|
+
throw new ValidationError("Command timeout must be a positive number.");
|
|
969
|
+
}
|
|
970
|
+
if (options.maxBuffer !== void 0 && (!Number.isInteger(options.maxBuffer) || options.maxBuffer <= 0)) {
|
|
971
|
+
throw new ValidationError("Command maxBuffer must be a positive integer.");
|
|
972
|
+
}
|
|
973
|
+
if (options.failOnStderr !== void 0 && typeof options.failOnStderr !== "boolean") {
|
|
974
|
+
throw new ValidationError("Command failOnStderr must be a boolean.");
|
|
975
|
+
}
|
|
976
|
+
await this.ensureCommandExecutorConnected();
|
|
977
|
+
let result;
|
|
978
|
+
try {
|
|
979
|
+
result = await this.commandExecutor.exec(command, options);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
if (error instanceof RemoteCommandError) {
|
|
982
|
+
throw new RemoteCommandError(error.message, {
|
|
983
|
+
exitCode: error.exitCode,
|
|
984
|
+
signal: error.signal,
|
|
985
|
+
stdout: redactKnownSensitiveValues(error.stdout, this.serverOptions),
|
|
986
|
+
stderr: redactKnownSensitiveValues(error.stderr, this.serverOptions)
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
throw error;
|
|
990
|
+
}
|
|
991
|
+
if (result.exitCode !== 0 || options.failOnStderr && result.stderr.length > 0) {
|
|
992
|
+
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.";
|
|
993
|
+
throw new RemoteCommandError(reason, {
|
|
994
|
+
exitCode: result.exitCode,
|
|
995
|
+
signal: result.signal,
|
|
996
|
+
stdout: redactKnownSensitiveValues(result.stdout, this.serverOptions),
|
|
997
|
+
stderr: redactKnownSensitiveValues(result.stderr, this.serverOptions)
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return result;
|
|
1001
|
+
}
|
|
664
1002
|
async close() {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1003
|
+
const closures = [];
|
|
1004
|
+
if (this.connected) closures.push(this.transport.close());
|
|
1005
|
+
if (this.commandExecutorConnected) closures.push(this.commandExecutor.close());
|
|
1006
|
+
const results = await Promise.allSettled(closures);
|
|
1007
|
+
this.connected = false;
|
|
1008
|
+
this.commandExecutorConnected = false;
|
|
1009
|
+
const failure = results.find(
|
|
1010
|
+
(result) => result.status === "rejected"
|
|
1011
|
+
);
|
|
1012
|
+
if (failure) {
|
|
1013
|
+
throw failure.reason;
|
|
668
1014
|
}
|
|
669
1015
|
}
|
|
670
1016
|
async ensureConnected() {
|
|
@@ -672,6 +1018,13 @@ var ScpNextClientImpl = class {
|
|
|
672
1018
|
await this.connect();
|
|
673
1019
|
}
|
|
674
1020
|
}
|
|
1021
|
+
async ensureCommandExecutorConnected() {
|
|
1022
|
+
if (!this.commandExecutorConnected) {
|
|
1023
|
+
await validateServerOptions(this.serverOptions);
|
|
1024
|
+
await this.commandExecutor.connect(this.serverOptions);
|
|
1025
|
+
this.commandExecutorConnected = true;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
675
1028
|
async uploadWithTransport(options) {
|
|
676
1029
|
const localKind = await getLocalPathKind(options.localPath);
|
|
677
1030
|
const remotePath = normalizeRemotePath(options.remotePath);
|
|
@@ -957,11 +1310,18 @@ async function download(options) {
|
|
|
957
1310
|
// src/client/upload.ts
|
|
958
1311
|
async function upload(options) {
|
|
959
1312
|
const client = new ScpNextClientImpl(options);
|
|
1313
|
+
let result;
|
|
960
1314
|
try {
|
|
961
|
-
await client.upload(options.localPath, options.remotePath, options);
|
|
962
|
-
}
|
|
963
|
-
|
|
1315
|
+
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
try {
|
|
1318
|
+
await client.close();
|
|
1319
|
+
} catch {
|
|
1320
|
+
}
|
|
1321
|
+
throw error;
|
|
964
1322
|
}
|
|
1323
|
+
await client.close();
|
|
1324
|
+
return result;
|
|
965
1325
|
}
|
|
966
1326
|
|
|
967
1327
|
// src/cli/commands/download.ts
|
|
@@ -1043,7 +1403,8 @@ var transferSchema = z.object({
|
|
|
1043
1403
|
overwrite: z.boolean().optional(),
|
|
1044
1404
|
createDirectories: z.boolean().optional(),
|
|
1045
1405
|
dryRun: z.boolean().optional(),
|
|
1046
|
-
timeout: z.number().int().optional()
|
|
1406
|
+
timeout: z.number().int().optional(),
|
|
1407
|
+
postUploadCommands: z.array(z.string().refine((command) => command.trim().length > 0)).optional()
|
|
1047
1408
|
});
|
|
1048
1409
|
var jobSchema = transferSchema.extend({
|
|
1049
1410
|
operation: z.enum(["upload", "download"]),
|
|
@@ -1115,6 +1476,7 @@ async function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
|
1115
1476
|
|
|
1116
1477
|
// src/config/resolve-config.ts
|
|
1117
1478
|
import path6 from "path";
|
|
1479
|
+
import { assignDefined } from "mazey";
|
|
1118
1480
|
|
|
1119
1481
|
// src/config/jobs.ts
|
|
1120
1482
|
function selectJob(config, jobName) {
|
|
@@ -1149,17 +1511,6 @@ var DEFAULT_TRANSFER = {
|
|
|
1149
1511
|
createDirectories: true,
|
|
1150
1512
|
dryRun: false
|
|
1151
1513
|
};
|
|
1152
|
-
function assignDefined(target, source) {
|
|
1153
|
-
if (!source) {
|
|
1154
|
-
return target;
|
|
1155
|
-
}
|
|
1156
|
-
for (const [key, value] of Object.entries(source)) {
|
|
1157
|
-
if (value !== void 0) {
|
|
1158
|
-
Object.assign(target, { [key]: value });
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
return target;
|
|
1162
|
-
}
|
|
1163
1514
|
function rootServer(config) {
|
|
1164
1515
|
const result = {};
|
|
1165
1516
|
assignDefined(result, config.server);
|
|
@@ -1188,7 +1539,8 @@ function jobTransfer(job) {
|
|
|
1188
1539
|
overwrite: job.overwrite,
|
|
1189
1540
|
createDirectories: job.createDirectories,
|
|
1190
1541
|
dryRun: job.dryRun,
|
|
1191
|
-
timeout: job.timeout
|
|
1542
|
+
timeout: job.timeout,
|
|
1543
|
+
postUploadCommands: readPostUploadCommands(job)
|
|
1192
1544
|
});
|
|
1193
1545
|
}
|
|
1194
1546
|
function resolveConfigRelatedPaths(resolved, operation, configDirectory) {
|
|
@@ -1240,15 +1592,14 @@ function resolveTransferConfig(input) {
|
|
|
1240
1592
|
passphrase: env.passphrase,
|
|
1241
1593
|
timeout: env.timeout
|
|
1242
1594
|
});
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
destination: input.destination
|
|
1246
|
-
});
|
|
1595
|
+
if (input.source !== void 0) resolved.source = input.source;
|
|
1596
|
+
if (input.destination !== void 0) resolved.destination = input.destination;
|
|
1247
1597
|
assignDefined(resolved, cli);
|
|
1248
1598
|
if (selectedProfileName) {
|
|
1249
1599
|
resolved.profile = selectedProfileName;
|
|
1250
1600
|
}
|
|
1251
1601
|
const pathResolved = resolveConfigRelatedPaths(resolved, operation, configDirectory);
|
|
1602
|
+
getPostUploadCommands(pathResolved);
|
|
1252
1603
|
if (input.source && operation === "upload") {
|
|
1253
1604
|
pathResolved.source = resolveLocalPath(input.source, cwd);
|
|
1254
1605
|
}
|
|
@@ -1257,6 +1608,10 @@ function resolveTransferConfig(input) {
|
|
|
1257
1608
|
}
|
|
1258
1609
|
if (operation === "download") {
|
|
1259
1610
|
pathResolved.source = restoreMsysConvertedRemotePath(pathResolved.source);
|
|
1611
|
+
if (getPostUploadCommands(job)?.length || getPostUploadCommands(cli)?.length) {
|
|
1612
|
+
throw new ValidationError("Post-upload commands are not supported for downloads.");
|
|
1613
|
+
}
|
|
1614
|
+
clearPostUploadCommands(pathResolved);
|
|
1260
1615
|
}
|
|
1261
1616
|
if (input.destination && operation === "download") {
|
|
1262
1617
|
pathResolved.destination = resolveLocalPath(input.destination, cwd);
|
|
@@ -1265,7 +1620,9 @@ function resolveTransferConfig(input) {
|
|
|
1265
1620
|
}
|
|
1266
1621
|
|
|
1267
1622
|
// src/cli/output.ts
|
|
1623
|
+
import { formatByteSize } from "mazey";
|
|
1268
1624
|
function writeDryRun(output, config) {
|
|
1625
|
+
const postUploadCommands = getPostUploadCommands(config) ?? [];
|
|
1269
1626
|
const destination = config.operation === "upload" ? `${config.username ?? "user"}@${config.host ?? "host"}:${config.destination}` : config.destination;
|
|
1270
1627
|
output.stdout.write(`Dry run: ${config.operation}
|
|
1271
1628
|
`);
|
|
@@ -1277,6 +1634,33 @@ function writeDryRun(output, config) {
|
|
|
1277
1634
|
`);
|
|
1278
1635
|
output.stdout.write(`Overwrite: ${config.overwrite ? "yes" : "no"}
|
|
1279
1636
|
`);
|
|
1637
|
+
if (config.operation === "upload" && postUploadCommands.length > 0) {
|
|
1638
|
+
output.stdout.write("\nPost-upload commands:\n");
|
|
1639
|
+
postUploadCommands.forEach((command, index) => {
|
|
1640
|
+
output.stdout.write(
|
|
1641
|
+
`${index + 1}. ${redactKnownSensitiveValues(command, config)}
|
|
1642
|
+
`
|
|
1643
|
+
);
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
function writeCommandResults(output, results, config) {
|
|
1648
|
+
results.forEach((result, index) => {
|
|
1649
|
+
output.stdout.write(`Remote command ${index + 1} completed successfully.
|
|
1650
|
+
`);
|
|
1651
|
+
if (result.stdout) {
|
|
1652
|
+
output.stdout.write(
|
|
1653
|
+
redactKnownSensitiveValues(result.stdout, config)
|
|
1654
|
+
);
|
|
1655
|
+
if (!result.stdout.endsWith("\n")) output.stdout.write("\n");
|
|
1656
|
+
}
|
|
1657
|
+
if (result.stderr) {
|
|
1658
|
+
output.stderr.write(
|
|
1659
|
+
redactKnownSensitiveValues(result.stderr, config)
|
|
1660
|
+
);
|
|
1661
|
+
if (!result.stderr.endsWith("\n")) output.stderr.write("\n");
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1280
1664
|
}
|
|
1281
1665
|
function createProgressReporter(output, quiet) {
|
|
1282
1666
|
if (quiet || !output.stderr.isTTY) {
|
|
@@ -1298,8 +1682,8 @@ function createProgressReporter(output, quiet) {
|
|
|
1298
1682
|
if (!isComplete && !percentageChanged && lastWriteAt !== 0 && now - lastWriteAt < minimumUpdateIntervalMs) {
|
|
1299
1683
|
return;
|
|
1300
1684
|
}
|
|
1301
|
-
const total = progress.totalBytes ?
|
|
1302
|
-
const transferred =
|
|
1685
|
+
const total = progress.totalBytes ? formatByteSize(progress.totalBytes) : "?";
|
|
1686
|
+
const transferred = formatByteSize(progress.transferredBytes);
|
|
1303
1687
|
const percentage2 = progress.percentage === void 0 ? "" : ` (${progress.percentage}%)`;
|
|
1304
1688
|
const currentFile = progress.currentFile ? `: ${progress.currentFile}` : "";
|
|
1305
1689
|
const line = `${progress.operation === "upload" ? "Uploading" : "Downloading"}${currentFile} ${transferred} / ${total}${percentage2}`;
|
|
@@ -1315,6 +1699,7 @@ function createProgressReporter(output, quiet) {
|
|
|
1315
1699
|
};
|
|
1316
1700
|
}
|
|
1317
1701
|
function verbosePlan(output, config) {
|
|
1702
|
+
const postUploadCommands = getPostUploadCommands(config);
|
|
1318
1703
|
output.stderr.write(
|
|
1319
1704
|
`${JSON.stringify(
|
|
1320
1705
|
redactSensitiveValues({
|
|
@@ -1327,7 +1712,10 @@ function verbosePlan(output, config) {
|
|
|
1327
1712
|
recursive: config.recursive,
|
|
1328
1713
|
overwrite: config.overwrite,
|
|
1329
1714
|
createDirectories: config.createDirectories,
|
|
1330
|
-
dryRun: config.dryRun
|
|
1715
|
+
dryRun: config.dryRun,
|
|
1716
|
+
postUploadCommands: postUploadCommands?.map(
|
|
1717
|
+
(_command, index) => `command-${index + 1}`
|
|
1718
|
+
)
|
|
1331
1719
|
}),
|
|
1332
1720
|
null,
|
|
1333
1721
|
2
|
|
@@ -1335,22 +1723,9 @@ function verbosePlan(output, config) {
|
|
|
1335
1723
|
`
|
|
1336
1724
|
);
|
|
1337
1725
|
}
|
|
1338
|
-
function formatBytes(bytes) {
|
|
1339
|
-
if (bytes < 1024) {
|
|
1340
|
-
return `${bytes} B`;
|
|
1341
|
-
}
|
|
1342
|
-
const units = ["KB", "MB", "GB", "TB"];
|
|
1343
|
-
let value = bytes / 1024;
|
|
1344
|
-
let unitIndex = 0;
|
|
1345
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
1346
|
-
value /= 1024;
|
|
1347
|
-
unitIndex += 1;
|
|
1348
|
-
}
|
|
1349
|
-
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
1350
|
-
}
|
|
1351
1726
|
|
|
1352
1727
|
// src/cli/commands/shared.ts
|
|
1353
|
-
function addTransferOptions(command) {
|
|
1728
|
+
function addTransferOptions(command, options = {}) {
|
|
1354
1729
|
command.option("--host <host>", "SSH server host");
|
|
1355
1730
|
command.addOption(new Option("--port <port>", "SSH server port").argParser(parseInteger2));
|
|
1356
1731
|
command.option("--username <username>", "SSH username");
|
|
@@ -1365,9 +1740,20 @@ function addTransferOptions(command) {
|
|
|
1365
1740
|
command.option("--create-directories", "Create missing destination directories");
|
|
1366
1741
|
command.option("--no-create-directories", "Do not create missing destination directories");
|
|
1367
1742
|
command.option("--dry-run", "Resolve and validate without connecting or transferring");
|
|
1368
|
-
command.addOption(
|
|
1743
|
+
command.addOption(
|
|
1744
|
+
new Option("--timeout <milliseconds>", "SSH connection ready timeout").argParser(
|
|
1745
|
+
parseInteger2
|
|
1746
|
+
)
|
|
1747
|
+
);
|
|
1369
1748
|
command.option("--verbose", "Print verbose diagnostic output");
|
|
1370
1749
|
command.option("--quiet", "Disable progress and non-error output");
|
|
1750
|
+
if (options.postUploadCommand) {
|
|
1751
|
+
command.option(
|
|
1752
|
+
"--post-upload-command <command>",
|
|
1753
|
+
"Run a remote command after a successful upload (repeatable)",
|
|
1754
|
+
collectValues
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1371
1757
|
return command;
|
|
1372
1758
|
}
|
|
1373
1759
|
function parseInteger2(value) {
|
|
@@ -1377,6 +1763,9 @@ function parseInteger2(value) {
|
|
|
1377
1763
|
}
|
|
1378
1764
|
return parsed;
|
|
1379
1765
|
}
|
|
1766
|
+
function collectValues(value, previous) {
|
|
1767
|
+
return [...previous ?? [], value];
|
|
1768
|
+
}
|
|
1380
1769
|
function toCliOptions(options) {
|
|
1381
1770
|
return {
|
|
1382
1771
|
host: options.host,
|
|
@@ -1391,10 +1780,16 @@ function toCliOptions(options) {
|
|
|
1391
1780
|
overwrite: options.overwrite,
|
|
1392
1781
|
createDirectories: options.createDirectories,
|
|
1393
1782
|
dryRun: options.dryRun,
|
|
1394
|
-
timeout: options.timeout
|
|
1783
|
+
timeout: options.timeout,
|
|
1784
|
+
postUploadCommands: options.postUploadCommand
|
|
1395
1785
|
};
|
|
1396
1786
|
}
|
|
1397
1787
|
async function resolveForCli(input) {
|
|
1788
|
+
if (input.options.postUploadCommand?.some((command) => !command.trim())) {
|
|
1789
|
+
throw new ValidationError(
|
|
1790
|
+
"Each --post-upload-command value must be a non-empty command."
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1398
1793
|
const loaded = await loadConfig(input.options.config, input.cwd);
|
|
1399
1794
|
return resolveTransferConfig({
|
|
1400
1795
|
operation: input.operation,
|
|
@@ -1426,7 +1821,10 @@ async function executeResolvedTransfer(config, handlers, output, commandOptions)
|
|
|
1426
1821
|
remotePath: config.destination
|
|
1427
1822
|
};
|
|
1428
1823
|
if (onProgress) uploadOptions.onProgress = onProgress;
|
|
1429
|
-
await handlers.upload(uploadOptions);
|
|
1824
|
+
const results = await handlers.upload(uploadOptions);
|
|
1825
|
+
if (!commandOptions.quiet) {
|
|
1826
|
+
writeCommandResults(output, results, config);
|
|
1827
|
+
}
|
|
1430
1828
|
return;
|
|
1431
1829
|
}
|
|
1432
1830
|
const downloadOptions = {
|
|
@@ -1470,7 +1868,7 @@ function createRunCommand(handlers, output, cwd) {
|
|
|
1470
1868
|
await executeResolvedTransfer(resolved, handlers, output, options);
|
|
1471
1869
|
}
|
|
1472
1870
|
);
|
|
1473
|
-
addTransferOptions(command);
|
|
1871
|
+
addTransferOptions(command, { postUploadCommand: true });
|
|
1474
1872
|
return command;
|
|
1475
1873
|
}
|
|
1476
1874
|
|
|
@@ -1488,7 +1886,7 @@ function createUploadCommand(handlers, output, cwd) {
|
|
|
1488
1886
|
});
|
|
1489
1887
|
await executeResolvedTransfer(resolved, handlers, output, options);
|
|
1490
1888
|
});
|
|
1491
|
-
addTransferOptions(command);
|
|
1889
|
+
addTransferOptions(command, { postUploadCommand: true });
|
|
1492
1890
|
return command;
|
|
1493
1891
|
}
|
|
1494
1892
|
|
|
@@ -1537,6 +1935,16 @@ async function runCli(options = {}) {
|
|
|
1537
1935
|
return error.exitCode || 1;
|
|
1538
1936
|
}
|
|
1539
1937
|
const verbose = (options.argv ?? process.argv).includes("--verbose");
|
|
1938
|
+
if (error instanceof RemoteCommandError) {
|
|
1939
|
+
if (error.stdout) {
|
|
1940
|
+
output.stdout.write(redactKnownSensitiveValues(error.stdout, {}));
|
|
1941
|
+
if (!error.stdout.endsWith("\n")) output.stdout.write("\n");
|
|
1942
|
+
}
|
|
1943
|
+
if (error.stderr) {
|
|
1944
|
+
output.stderr.write(redactKnownSensitiveValues(error.stderr, {}));
|
|
1945
|
+
if (!error.stderr.endsWith("\n")) output.stderr.write("\n");
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1540
1948
|
if (error instanceof ScpNextError) {
|
|
1541
1949
|
output.stderr.write(`Error: ${formatErrorMessage(error.message)}
|
|
1542
1950
|
`);
|