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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/client/client.ts
2
- import { access as access4, mkdir as mkdir2 } from "fs/promises";
2
+ 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
@@ -39,6 +39,12 @@ function redactSensitiveValues(value) {
39
39
  }
40
40
  return value;
41
41
  }
42
+ function formatErrorMessage(error) {
43
+ if (error instanceof Error) {
44
+ return String(redactSensitiveValues(error.message));
45
+ }
46
+ return String(redactSensitiveValues(error));
47
+ }
42
48
 
43
49
  // src/errors/index.ts
44
50
  var ScpNextError = class extends Error {
@@ -95,7 +101,7 @@ function toScpNextError(error) {
95
101
  if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
96
102
  return new AuthenticationError("SSH authentication failed.", { cause: error });
97
103
  }
98
- if (lowerMessage.includes("host verification") || lowerMessage.includes("host key")) {
104
+ if (lowerMessage.includes("host verification") || lowerMessage.includes("host key") || lowerMessage.includes("fingerprint") || lowerMessage.includes("host") && lowerMessage.includes("denied")) {
99
105
  return new HostVerificationError("SSH host verification failed.", { cause: error });
100
106
  }
101
107
  if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
@@ -169,8 +175,39 @@ async function getLocalPathKind(inputPath) {
169
175
  function normalizeFingerprint(fingerprint) {
170
176
  return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
171
177
  }
178
+ function isHexFingerprint(fingerprint) {
179
+ return /^[0-9a-f]+$/i.test(fingerprint);
180
+ }
181
+ function fingerprintCandidates(fingerprint) {
182
+ const normalized = normalizeFingerprint(fingerprint);
183
+ const candidates = /* @__PURE__ */ new Set([normalized]);
184
+ if (isHexFingerprint(normalized)) {
185
+ candidates.add(normalized.toLowerCase());
186
+ } else {
187
+ try {
188
+ candidates.add(Buffer.from(normalized, "base64").toString("hex"));
189
+ } catch {
190
+ }
191
+ }
192
+ return [...candidates];
193
+ }
172
194
  function isFingerprintMatch(actual, expected) {
173
- return normalizeFingerprint(actual) === normalizeFingerprint(expected);
195
+ const normalizedActual = normalizeFingerprint(actual);
196
+ const normalizedExpected = normalizeFingerprint(expected);
197
+ if (normalizedActual === normalizedExpected) {
198
+ return true;
199
+ }
200
+ return isHexFingerprint(normalizedActual) && isHexFingerprint(normalizedExpected) && normalizedActual.toLowerCase() === normalizedExpected.toLowerCase();
201
+ }
202
+ function hostVerificationHint(host) {
203
+ return [
204
+ "",
205
+ "Typical fix:",
206
+ ` ssh ${host}`,
207
+ "",
208
+ "If you trust the host key, accept it and retry the scp-next command.",
209
+ "Alternatively configure hostFingerprint or knownHostsFile explicitly."
210
+ ].join("\n");
174
211
  }
175
212
  async function resolveAllowedFingerprints(options) {
176
213
  const fingerprints = /* @__PURE__ */ new Set();
@@ -179,7 +216,9 @@ async function resolveAllowedFingerprints(options) {
179
216
  throw new HostVerificationError("A host is required for SSH host verification.");
180
217
  }
181
218
  if (options.hostFingerprint) {
182
- fingerprints.add(normalizeFingerprint(options.hostFingerprint));
219
+ for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
220
+ fingerprints.add(fingerprint);
221
+ }
183
222
  }
184
223
  try {
185
224
  await access2(expandHome(knownHostsFile));
@@ -190,11 +229,15 @@ async function resolveAllowedFingerprints(options) {
190
229
  continue;
191
230
  }
192
231
  const [hosts, algorithm, key] = trimmed.split(/\s+/);
193
- if (!hosts || !algorithm || !key || !hosts.split(",").includes(options.host)) {
232
+ const hostPatterns = hosts?.split(",") ?? [];
233
+ const portHost = options.port ? `[${options.host}]:${options.port}` : void 0;
234
+ const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
235
+ if (!hosts || !algorithm || !key || !matchesHost) {
194
236
  continue;
195
237
  }
196
- const fingerprint = createHash("sha256").update(Buffer.from(key, "base64")).digest("base64").replace(/=+$/g, "");
197
- fingerprints.add(fingerprint);
238
+ const keyBuffer = Buffer.from(key, "base64");
239
+ fingerprints.add(createHash("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
240
+ fingerprints.add(createHash("sha256").update(keyBuffer).digest("hex"));
198
241
  }
199
242
  } catch (error) {
200
243
  if (options.knownHostsFile || !options.hostFingerprint) {
@@ -206,7 +249,9 @@ async function resolveAllowedFingerprints(options) {
206
249
  }
207
250
  if (fingerprints.size === 0) {
208
251
  throw new HostVerificationError(
209
- "SSH host verification failed before connecting: no matching host fingerprint was configured."
252
+ `SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
253
+ options.host
254
+ )}`
210
255
  );
211
256
  }
212
257
  return fingerprints;
@@ -233,7 +278,11 @@ function toRemoteFileInfo(info) {
233
278
  var Ssh2SftpTransport = class {
234
279
  client;
235
280
  constructor(name = "scp-next") {
236
- this.client = new SftpClient(name);
281
+ this.client = new SftpClient(name, {
282
+ error: () => void 0,
283
+ end: () => void 0,
284
+ close: () => void 0
285
+ });
237
286
  }
238
287
  async connect(options) {
239
288
  const allowedFingerprints = await resolveAllowedFingerprints(options);
@@ -263,15 +312,24 @@ var Ssh2SftpTransport = class {
263
312
  } catch (error) {
264
313
  const converted = toScpNextError(error);
265
314
  if (converted instanceof HostVerificationError) {
266
- throw converted;
315
+ throw new HostVerificationError(
316
+ `SSH host verification failed during connection: ${formatErrorMessage(
317
+ error
318
+ )}.${hostVerificationTroubleshooting(options.host)}`,
319
+ { cause: error, context: connectionContext(options) }
320
+ );
321
+ }
322
+ if (isLikelyHostVerificationFailure(error)) {
323
+ throw new HostVerificationError(
324
+ `SSH host verification failed during connection.${hostVerificationTroubleshooting(
325
+ options.host
326
+ )}`,
327
+ { cause: error, context: connectionContext(options) }
328
+ );
267
329
  }
268
- throw new ConnectionError("Unable to connect to SSH server.", {
330
+ throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
269
331
  cause: error,
270
- context: {
271
- host: options.host,
272
- port: options.port,
273
- username: options.username
274
- }
332
+ context: connectionContext(options)
275
333
  });
276
334
  }
277
335
  }
@@ -292,7 +350,7 @@ var Ssh2SftpTransport = class {
292
350
  try {
293
351
  await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
294
352
  } catch (error) {
295
- throw new TransferError(`Upload failed for ${localPath}.`, {
353
+ throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
296
354
  cause: error,
297
355
  context: { localPath, remotePath }
298
356
  });
@@ -302,7 +360,7 @@ var Ssh2SftpTransport = class {
302
360
  try {
303
361
  await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
304
362
  } catch (error) {
305
- throw new TransferError(`Download failed for ${remotePath}.`, {
363
+ throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
306
364
  cause: error,
307
365
  context: { remotePath, localPath }
308
366
  });
@@ -319,6 +377,31 @@ var Ssh2SftpTransport = class {
319
377
  };
320
378
  }
321
379
  };
380
+ function connectionContext(options) {
381
+ return {
382
+ host: options.host,
383
+ port: options.port,
384
+ username: options.username
385
+ };
386
+ }
387
+ function isLikelyHostVerificationFailure(error) {
388
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
389
+ return message.includes("host") && (message.includes("verif") || message.includes("fingerprint") || message.includes("host key") || message.includes("key mismatch") || message.includes("denied"));
390
+ }
391
+ function hostVerificationTroubleshooting(host) {
392
+ const displayHost = host ?? "<host>";
393
+ return [
394
+ "",
395
+ "",
396
+ "Typical fix:",
397
+ ` ssh ${displayHost}`,
398
+ "",
399
+ "If plain ssh works but scp-next fails, check that the host entry is available in",
400
+ "`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
401
+ "OpenSSH usually stores entries as `[host]:port`.",
402
+ "You can also configure hostFingerprint or knownHostsFile explicitly."
403
+ ].join("\n");
404
+ }
322
405
 
323
406
  // src/client/walk.ts
324
407
  import { readdir, stat as stat2 } from "fs/promises";
@@ -489,6 +572,22 @@ function percentage(transferredBytes, totalBytes) {
489
572
  function mergeOptions(serverOptions, transferOptions) {
490
573
  return { ...serverOptions, ...transferOptions ?? {} };
491
574
  }
575
+ function hasTrailingPathSeparator(inputPath) {
576
+ return /[\\/]$/.test(inputPath);
577
+ }
578
+ function trimmedRemoteDirectory(remotePath) {
579
+ return remotePath.replace(/\/+$/g, "") || "/";
580
+ }
581
+ function remoteBasename(remotePath) {
582
+ return path4.posix.basename(trimmedRemoteDirectory(remotePath));
583
+ }
584
+ async function isExistingLocalDirectory(localPath) {
585
+ try {
586
+ return (await stat3(localPath)).isDirectory();
587
+ } catch {
588
+ return false;
589
+ }
590
+ }
492
591
  var ScpNextClientImpl = class {
493
592
  serverOptions;
494
593
  transport;
@@ -546,10 +645,9 @@ var ScpNextClientImpl = class {
546
645
  throw new ValidationError("Uploading a directory requires recursive mode.");
547
646
  }
548
647
  if (localKind === "file") {
549
- await this.ensureRemoteParent(remotePath, options.createDirectories);
550
- await this.assertRemoteOverwrite(remotePath, options.overwrite);
648
+ const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
551
649
  const totalBytes2 = void 0;
552
- await this.transport.uploadFile(resolveLocalPath(options.localPath), remotePath, (step) => {
650
+ await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
553
651
  options.onProgress?.({
554
652
  operation: "upload",
555
653
  source: options.localPath,
@@ -566,22 +664,15 @@ var ScpNextClientImpl = class {
566
664
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
567
665
  let completedBytes = 0;
568
666
  let completedFiles = 0;
569
- const destinationExists = await this.transport.exists(remotePath);
570
- if (destinationExists && destinationExists !== "d") {
571
- throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
572
- context: { remotePath }
573
- });
574
- }
575
- if (!destinationExists) {
576
- if (!options.createDirectories) {
577
- throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
578
- context: { remotePath }
579
- });
580
- }
581
- await this.transport.mkdir(remotePath, true);
582
- }
667
+ const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(
668
+ options,
669
+ remotePath
670
+ );
583
671
  for (const file of files) {
584
- const destinationFile = remoteJoin(remotePath, file.relativePath.split(path4.sep).join("/"));
672
+ const destinationFile = remoteJoin(
673
+ destinationRoot,
674
+ file.relativePath.split(path4.sep).join("/")
675
+ );
585
676
  await this.ensureRemoteParent(destinationFile, true);
586
677
  await this.assertRemoteOverwrite(destinationFile, options.overwrite);
587
678
  await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
@@ -602,6 +693,68 @@ var ScpNextClientImpl = class {
602
693
  completedFiles += 1;
603
694
  }
604
695
  }
696
+ async resolveRemoteDestinationForLocalFile(options, remotePath) {
697
+ const remoteKind = await this.transport.exists(remotePath);
698
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
699
+ if (!isDirectoryLikeDestination) {
700
+ await this.ensureRemoteParent(remotePath, options.createDirectories);
701
+ await this.assertRemoteOverwrite(remotePath, options.overwrite);
702
+ return remotePath;
703
+ }
704
+ if (remoteKind && remoteKind !== "d") {
705
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
706
+ context: { remotePath }
707
+ });
708
+ }
709
+ const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
710
+ if (!remoteKind) {
711
+ if (!options.createDirectories) {
712
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
713
+ context: { remotePath }
714
+ });
715
+ }
716
+ await this.transport.mkdir(remoteDirectory, true);
717
+ }
718
+ const destinationFile = remoteJoin(
719
+ remoteDirectory,
720
+ path4.basename(resolveLocalPath(options.localPath))
721
+ );
722
+ await this.assertRemoteOverwrite(destinationFile, options.overwrite);
723
+ return destinationFile;
724
+ }
725
+ async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
726
+ const remoteKind = await this.transport.exists(remotePath);
727
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
728
+ const localDirectoryName = path4.basename(resolveLocalPath(options.localPath));
729
+ if (remoteKind && remoteKind !== "d") {
730
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
731
+ context: { remotePath }
732
+ });
733
+ }
734
+ if (isDirectoryLikeDestination) {
735
+ const remoteDirectory = trimmedRemoteDirectory(remotePath);
736
+ if (!remoteKind) {
737
+ if (!options.createDirectories) {
738
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
739
+ context: { remotePath }
740
+ });
741
+ }
742
+ await this.transport.mkdir(remoteDirectory, true);
743
+ }
744
+ const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
745
+ await this.ensureRemoteDirectory(destinationRoot);
746
+ return destinationRoot;
747
+ }
748
+ if (!remoteKind) {
749
+ if (!options.createDirectories) {
750
+ throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
751
+ context: { remotePath }
752
+ });
753
+ }
754
+ await this.transport.mkdir(remotePath, true);
755
+ }
756
+ return remotePath;
757
+ }
605
758
  async downloadWithTransport(options) {
606
759
  const remotePath = normalizeRemotePath(options.remotePath);
607
760
  const remoteKind = await this.transport.exists(remotePath);
@@ -620,7 +773,7 @@ var ScpNextClientImpl = class {
620
773
  await this.downloadSingleFile(options, remotePath);
621
774
  }
622
775
  async downloadSingleFile(options, remotePath) {
623
- const localPath = resolveLocalPath(options.localPath);
776
+ const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
624
777
  await this.assertLocalOverwrite(localPath, options.overwrite);
625
778
  if (options.createDirectories) {
626
779
  await mkdir2(path4.dirname(localPath), { recursive: true });
@@ -639,7 +792,7 @@ var ScpNextClientImpl = class {
639
792
  }
640
793
  async downloadDirectory(options) {
641
794
  const remotePath = normalizeRemotePath(options.remotePath);
642
- const localRoot = resolveLocalPath(options.localPath);
795
+ const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
643
796
  const files = await walkRemoteFiles(this.transport, remotePath);
644
797
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
645
798
  let completedBytes = 0;
@@ -667,6 +820,38 @@ var ScpNextClientImpl = class {
667
820
  completedFiles += 1;
668
821
  }
669
822
  }
823
+ async resolveLocalDestinationForRemoteFile(options, remotePath) {
824
+ const localDestination = resolveLocalPath(options.localPath);
825
+ const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
826
+ if (!isDirectoryLikeDestination) {
827
+ return localDestination;
828
+ }
829
+ if (!await isExistingLocalDirectory(localDestination)) {
830
+ if (!options.createDirectories) {
831
+ throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
832
+ context: { localPath: options.localPath }
833
+ });
834
+ }
835
+ await mkdir2(localDestination, { recursive: true });
836
+ }
837
+ return path4.join(localDestination, remoteBasename(remotePath));
838
+ }
839
+ async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
840
+ const localDestination = resolveLocalPath(options.localPath);
841
+ const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
842
+ if (!isDirectoryLikeDestination) {
843
+ return localDestination;
844
+ }
845
+ if (!await isExistingLocalDirectory(localDestination)) {
846
+ if (!options.createDirectories) {
847
+ throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
848
+ context: { localPath: options.localPath }
849
+ });
850
+ }
851
+ await mkdir2(localDestination, { recursive: true });
852
+ }
853
+ return path4.join(localDestination, remoteBasename(remotePath));
854
+ }
670
855
  async ensureRemoteParent(remotePath, createDirectories = false) {
671
856
  const parent = remoteDirname(remotePath);
672
857
  if (parent === "." || parent === "/") {
@@ -683,6 +868,17 @@ var ScpNextClientImpl = class {
683
868
  });
684
869
  }
685
870
  }
871
+ async ensureRemoteDirectory(remotePath) {
872
+ const exists = await this.transport.exists(remotePath);
873
+ if (exists && exists !== "d") {
874
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
875
+ context: { remotePath }
876
+ });
877
+ }
878
+ if (!exists) {
879
+ await this.transport.mkdir(remotePath, true);
880
+ }
881
+ }
686
882
  async assertRemoteOverwrite(remotePath, overwrite = false) {
687
883
  if (overwrite) {
688
884
  return;