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/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 String(redactSensitiveValues(error.message));
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 String(redactSensitiveValues(error));
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 allowedFingerprints = await resolveAllowedFingerprints(options);
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
- if (this.connected) {
700
- await this.transport.close();
701
- this.connected = false;
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
- } finally {
990
- await client.close();
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,