scp-next 1.0.4 → 1.0.6

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
@@ -88,6 +88,12 @@ function redactSensitiveValues(value) {
88
88
  }
89
89
  return value;
90
90
  }
91
+ function formatErrorMessage(error) {
92
+ if (error instanceof Error) {
93
+ return String(redactSensitiveValues(error.message));
94
+ }
95
+ return String(redactSensitiveValues(error));
96
+ }
91
97
 
92
98
  // src/errors/index.ts
93
99
  var ScpNextError = class extends Error {
@@ -144,7 +150,7 @@ function toScpNextError(error) {
144
150
  if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
145
151
  return new AuthenticationError("SSH authentication failed.", { cause: error });
146
152
  }
147
- if (lowerMessage.includes("host verification") || lowerMessage.includes("host key")) {
153
+ if (lowerMessage.includes("host verification") || lowerMessage.includes("host key") || lowerMessage.includes("fingerprint") || lowerMessage.includes("host") && lowerMessage.includes("denied")) {
148
154
  return new HostVerificationError("SSH host verification failed.", { cause: error });
149
155
  }
150
156
  if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
@@ -218,8 +224,39 @@ async function getLocalPathKind(inputPath) {
218
224
  function normalizeFingerprint(fingerprint) {
219
225
  return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
220
226
  }
227
+ function isHexFingerprint(fingerprint) {
228
+ return /^[0-9a-f]+$/i.test(fingerprint);
229
+ }
230
+ function fingerprintCandidates(fingerprint) {
231
+ const normalized = normalizeFingerprint(fingerprint);
232
+ const candidates = /* @__PURE__ */ new Set([normalized]);
233
+ if (isHexFingerprint(normalized)) {
234
+ candidates.add(normalized.toLowerCase());
235
+ } else {
236
+ try {
237
+ candidates.add(Buffer.from(normalized, "base64").toString("hex"));
238
+ } catch {
239
+ }
240
+ }
241
+ return [...candidates];
242
+ }
221
243
  function isFingerprintMatch(actual, expected) {
222
- return normalizeFingerprint(actual) === normalizeFingerprint(expected);
244
+ const normalizedActual = normalizeFingerprint(actual);
245
+ const normalizedExpected = normalizeFingerprint(expected);
246
+ if (normalizedActual === normalizedExpected) {
247
+ return true;
248
+ }
249
+ return isHexFingerprint(normalizedActual) && isHexFingerprint(normalizedExpected) && normalizedActual.toLowerCase() === normalizedExpected.toLowerCase();
250
+ }
251
+ function hostVerificationHint(host) {
252
+ return [
253
+ "",
254
+ "Typical fix:",
255
+ ` ssh ${host}`,
256
+ "",
257
+ "If you trust the host key, accept it and retry the scp-next command.",
258
+ "Alternatively configure hostFingerprint or knownHostsFile explicitly."
259
+ ].join("\n");
223
260
  }
224
261
  async function resolveAllowedFingerprints(options) {
225
262
  const fingerprints = /* @__PURE__ */ new Set();
@@ -228,7 +265,9 @@ async function resolveAllowedFingerprints(options) {
228
265
  throw new HostVerificationError("A host is required for SSH host verification.");
229
266
  }
230
267
  if (options.hostFingerprint) {
231
- fingerprints.add(normalizeFingerprint(options.hostFingerprint));
268
+ for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
269
+ fingerprints.add(fingerprint);
270
+ }
232
271
  }
233
272
  try {
234
273
  await (0, import_promises2.access)(expandHome(knownHostsFile));
@@ -239,11 +278,15 @@ async function resolveAllowedFingerprints(options) {
239
278
  continue;
240
279
  }
241
280
  const [hosts, algorithm, key] = trimmed.split(/\s+/);
242
- if (!hosts || !algorithm || !key || !hosts.split(",").includes(options.host)) {
281
+ const hostPatterns = hosts?.split(",") ?? [];
282
+ const portHost = options.port ? `[${options.host}]:${options.port}` : void 0;
283
+ const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
284
+ if (!hosts || !algorithm || !key || !matchesHost) {
243
285
  continue;
244
286
  }
245
- const fingerprint = (0, import_node_crypto.createHash)("sha256").update(Buffer.from(key, "base64")).digest("base64").replace(/=+$/g, "");
246
- fingerprints.add(fingerprint);
287
+ const keyBuffer = Buffer.from(key, "base64");
288
+ fingerprints.add((0, import_node_crypto.createHash)("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
289
+ fingerprints.add((0, import_node_crypto.createHash)("sha256").update(keyBuffer).digest("hex"));
247
290
  }
248
291
  } catch (error) {
249
292
  if (options.knownHostsFile || !options.hostFingerprint) {
@@ -255,7 +298,9 @@ async function resolveAllowedFingerprints(options) {
255
298
  }
256
299
  if (fingerprints.size === 0) {
257
300
  throw new HostVerificationError(
258
- "SSH host verification failed before connecting: no matching host fingerprint was configured."
301
+ `SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
302
+ options.host
303
+ )}`
259
304
  );
260
305
  }
261
306
  return fingerprints;
@@ -282,7 +327,11 @@ function toRemoteFileInfo(info) {
282
327
  var Ssh2SftpTransport = class {
283
328
  client;
284
329
  constructor(name = "scp-next") {
285
- this.client = new import_ssh2_sftp_client.default(name);
330
+ this.client = new import_ssh2_sftp_client.default(name, {
331
+ error: () => void 0,
332
+ end: () => void 0,
333
+ close: () => void 0
334
+ });
286
335
  }
287
336
  async connect(options) {
288
337
  const allowedFingerprints = await resolveAllowedFingerprints(options);
@@ -312,15 +361,24 @@ var Ssh2SftpTransport = class {
312
361
  } catch (error) {
313
362
  const converted = toScpNextError(error);
314
363
  if (converted instanceof HostVerificationError) {
315
- throw converted;
364
+ throw new HostVerificationError(
365
+ `SSH host verification failed during connection: ${formatErrorMessage(
366
+ error
367
+ )}.${hostVerificationTroubleshooting(options.host)}`,
368
+ { cause: error, context: connectionContext(options) }
369
+ );
370
+ }
371
+ if (isLikelyHostVerificationFailure(error)) {
372
+ throw new HostVerificationError(
373
+ `SSH host verification failed during connection.${hostVerificationTroubleshooting(
374
+ options.host
375
+ )}`,
376
+ { cause: error, context: connectionContext(options) }
377
+ );
316
378
  }
317
- throw new ConnectionError("Unable to connect to SSH server.", {
379
+ throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
318
380
  cause: error,
319
- context: {
320
- host: options.host,
321
- port: options.port,
322
- username: options.username
323
- }
381
+ context: connectionContext(options)
324
382
  });
325
383
  }
326
384
  }
@@ -341,7 +399,7 @@ var Ssh2SftpTransport = class {
341
399
  try {
342
400
  await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
343
401
  } catch (error) {
344
- throw new TransferError(`Upload failed for ${localPath}.`, {
402
+ throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
345
403
  cause: error,
346
404
  context: { localPath, remotePath }
347
405
  });
@@ -351,7 +409,7 @@ var Ssh2SftpTransport = class {
351
409
  try {
352
410
  await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
353
411
  } catch (error) {
354
- throw new TransferError(`Download failed for ${remotePath}.`, {
412
+ throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
355
413
  cause: error,
356
414
  context: { remotePath, localPath }
357
415
  });
@@ -368,6 +426,31 @@ var Ssh2SftpTransport = class {
368
426
  };
369
427
  }
370
428
  };
429
+ function connectionContext(options) {
430
+ return {
431
+ host: options.host,
432
+ port: options.port,
433
+ username: options.username
434
+ };
435
+ }
436
+ function isLikelyHostVerificationFailure(error) {
437
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
438
+ return message.includes("host") && (message.includes("verif") || message.includes("fingerprint") || message.includes("host key") || message.includes("key mismatch") || message.includes("denied"));
439
+ }
440
+ function hostVerificationTroubleshooting(host) {
441
+ const displayHost = host ?? "<host>";
442
+ return [
443
+ "",
444
+ "",
445
+ "Typical fix:",
446
+ ` ssh ${displayHost}`,
447
+ "",
448
+ "If plain ssh works but scp-next fails, check that the host entry is available in",
449
+ "`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
450
+ "OpenSSH usually stores entries as `[host]:port`.",
451
+ "You can also configure hostFingerprint or knownHostsFile explicitly."
452
+ ].join("\n");
453
+ }
371
454
 
372
455
  // src/client/walk.ts
373
456
  var import_promises4 = require("fs/promises");
@@ -538,6 +621,22 @@ function percentage(transferredBytes, totalBytes) {
538
621
  function mergeOptions(serverOptions, transferOptions) {
539
622
  return { ...serverOptions, ...transferOptions ?? {} };
540
623
  }
624
+ function hasTrailingPathSeparator(inputPath) {
625
+ return /[\\/]$/.test(inputPath);
626
+ }
627
+ function trimmedRemoteDirectory(remotePath) {
628
+ return remotePath.replace(/\/+$/g, "") || "/";
629
+ }
630
+ function remoteBasename(remotePath) {
631
+ return import_node_path4.default.posix.basename(trimmedRemoteDirectory(remotePath));
632
+ }
633
+ async function isExistingLocalDirectory(localPath) {
634
+ try {
635
+ return (await (0, import_promises6.stat)(localPath)).isDirectory();
636
+ } catch {
637
+ return false;
638
+ }
639
+ }
541
640
  var ScpNextClientImpl = class {
542
641
  serverOptions;
543
642
  transport;
@@ -595,10 +694,9 @@ var ScpNextClientImpl = class {
595
694
  throw new ValidationError("Uploading a directory requires recursive mode.");
596
695
  }
597
696
  if (localKind === "file") {
598
- await this.ensureRemoteParent(remotePath, options.createDirectories);
599
- await this.assertRemoteOverwrite(remotePath, options.overwrite);
697
+ const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
600
698
  const totalBytes2 = void 0;
601
- await this.transport.uploadFile(resolveLocalPath(options.localPath), remotePath, (step) => {
699
+ await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
602
700
  options.onProgress?.({
603
701
  operation: "upload",
604
702
  source: options.localPath,
@@ -615,22 +713,15 @@ var ScpNextClientImpl = class {
615
713
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
616
714
  let completedBytes = 0;
617
715
  let completedFiles = 0;
618
- const destinationExists = await this.transport.exists(remotePath);
619
- if (destinationExists && destinationExists !== "d") {
620
- throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
621
- context: { remotePath }
622
- });
623
- }
624
- if (!destinationExists) {
625
- if (!options.createDirectories) {
626
- throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
627
- context: { remotePath }
628
- });
629
- }
630
- await this.transport.mkdir(remotePath, true);
631
- }
716
+ const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(
717
+ options,
718
+ remotePath
719
+ );
632
720
  for (const file of files) {
633
- const destinationFile = remoteJoin(remotePath, file.relativePath.split(import_node_path4.default.sep).join("/"));
721
+ const destinationFile = remoteJoin(
722
+ destinationRoot,
723
+ file.relativePath.split(import_node_path4.default.sep).join("/")
724
+ );
634
725
  await this.ensureRemoteParent(destinationFile, true);
635
726
  await this.assertRemoteOverwrite(destinationFile, options.overwrite);
636
727
  await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
@@ -651,6 +742,68 @@ var ScpNextClientImpl = class {
651
742
  completedFiles += 1;
652
743
  }
653
744
  }
745
+ async resolveRemoteDestinationForLocalFile(options, remotePath) {
746
+ const remoteKind = await this.transport.exists(remotePath);
747
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
748
+ if (!isDirectoryLikeDestination) {
749
+ await this.ensureRemoteParent(remotePath, options.createDirectories);
750
+ await this.assertRemoteOverwrite(remotePath, options.overwrite);
751
+ return remotePath;
752
+ }
753
+ if (remoteKind && remoteKind !== "d") {
754
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
755
+ context: { remotePath }
756
+ });
757
+ }
758
+ const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
759
+ if (!remoteKind) {
760
+ if (!options.createDirectories) {
761
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
762
+ context: { remotePath }
763
+ });
764
+ }
765
+ await this.transport.mkdir(remoteDirectory, true);
766
+ }
767
+ const destinationFile = remoteJoin(
768
+ remoteDirectory,
769
+ import_node_path4.default.basename(resolveLocalPath(options.localPath))
770
+ );
771
+ await this.assertRemoteOverwrite(destinationFile, options.overwrite);
772
+ return destinationFile;
773
+ }
774
+ async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
775
+ const remoteKind = await this.transport.exists(remotePath);
776
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
777
+ const localDirectoryName = import_node_path4.default.basename(resolveLocalPath(options.localPath));
778
+ if (remoteKind && remoteKind !== "d") {
779
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
780
+ context: { remotePath }
781
+ });
782
+ }
783
+ if (isDirectoryLikeDestination) {
784
+ const remoteDirectory = trimmedRemoteDirectory(remotePath);
785
+ if (!remoteKind) {
786
+ if (!options.createDirectories) {
787
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
788
+ context: { remotePath }
789
+ });
790
+ }
791
+ await this.transport.mkdir(remoteDirectory, true);
792
+ }
793
+ const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
794
+ await this.ensureRemoteDirectory(destinationRoot);
795
+ return destinationRoot;
796
+ }
797
+ if (!remoteKind) {
798
+ if (!options.createDirectories) {
799
+ throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
800
+ context: { remotePath }
801
+ });
802
+ }
803
+ await this.transport.mkdir(remotePath, true);
804
+ }
805
+ return remotePath;
806
+ }
654
807
  async downloadWithTransport(options) {
655
808
  const remotePath = normalizeRemotePath(options.remotePath);
656
809
  const remoteKind = await this.transport.exists(remotePath);
@@ -669,7 +822,7 @@ var ScpNextClientImpl = class {
669
822
  await this.downloadSingleFile(options, remotePath);
670
823
  }
671
824
  async downloadSingleFile(options, remotePath) {
672
- const localPath = resolveLocalPath(options.localPath);
825
+ const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
673
826
  await this.assertLocalOverwrite(localPath, options.overwrite);
674
827
  if (options.createDirectories) {
675
828
  await (0, import_promises6.mkdir)(import_node_path4.default.dirname(localPath), { recursive: true });
@@ -688,7 +841,7 @@ var ScpNextClientImpl = class {
688
841
  }
689
842
  async downloadDirectory(options) {
690
843
  const remotePath = normalizeRemotePath(options.remotePath);
691
- const localRoot = resolveLocalPath(options.localPath);
844
+ const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
692
845
  const files = await walkRemoteFiles(this.transport, remotePath);
693
846
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
694
847
  let completedBytes = 0;
@@ -716,6 +869,38 @@ var ScpNextClientImpl = class {
716
869
  completedFiles += 1;
717
870
  }
718
871
  }
872
+ async resolveLocalDestinationForRemoteFile(options, remotePath) {
873
+ const localDestination = resolveLocalPath(options.localPath);
874
+ const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
875
+ if (!isDirectoryLikeDestination) {
876
+ return localDestination;
877
+ }
878
+ if (!await isExistingLocalDirectory(localDestination)) {
879
+ if (!options.createDirectories) {
880
+ throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
881
+ context: { localPath: options.localPath }
882
+ });
883
+ }
884
+ await (0, import_promises6.mkdir)(localDestination, { recursive: true });
885
+ }
886
+ return import_node_path4.default.join(localDestination, remoteBasename(remotePath));
887
+ }
888
+ async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
889
+ const localDestination = resolveLocalPath(options.localPath);
890
+ const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
891
+ if (!isDirectoryLikeDestination) {
892
+ return localDestination;
893
+ }
894
+ if (!await isExistingLocalDirectory(localDestination)) {
895
+ if (!options.createDirectories) {
896
+ throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
897
+ context: { localPath: options.localPath }
898
+ });
899
+ }
900
+ await (0, import_promises6.mkdir)(localDestination, { recursive: true });
901
+ }
902
+ return import_node_path4.default.join(localDestination, remoteBasename(remotePath));
903
+ }
719
904
  async ensureRemoteParent(remotePath, createDirectories = false) {
720
905
  const parent = remoteDirname(remotePath);
721
906
  if (parent === "." || parent === "/") {
@@ -732,6 +917,17 @@ var ScpNextClientImpl = class {
732
917
  });
733
918
  }
734
919
  }
920
+ async ensureRemoteDirectory(remotePath) {
921
+ const exists = await this.transport.exists(remotePath);
922
+ if (exists && exists !== "d") {
923
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
924
+ context: { remotePath }
925
+ });
926
+ }
927
+ if (!exists) {
928
+ await this.transport.mkdir(remotePath, true);
929
+ }
930
+ }
735
931
  async assertRemoteOverwrite(remotePath, overwrite = false) {
736
932
  if (overwrite) {
737
933
  return;