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/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 String(redactSensitiveValues(error.message));
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 String(redactSensitiveValues(error));
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 allowedFingerprints = await resolveAllowedFingerprints(options);
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
- if (this.connected) {
666
- await this.transport.close();
667
- this.connected = false;
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
- } finally {
963
- await client.close();
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
- assignDefined(resolved, {
1244
- source: input.source,
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 ? formatBytes(progress.totalBytes) : "?";
1302
- const transferred = formatBytes(progress.transferredBytes);
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(new Option("--timeout <milliseconds>", "Connection/operation timeout").argParser(parseInteger2));
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
  `);