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