scp-next 1.0.4 → 1.0.5

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
@@ -109,7 +109,7 @@ function toScpNextError(error) {
109
109
  if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
110
110
  return new AuthenticationError("SSH authentication failed.", { cause: error });
111
111
  }
112
- if (lowerMessage.includes("host verification") || lowerMessage.includes("host key")) {
112
+ if (lowerMessage.includes("host verification") || lowerMessage.includes("host key") || lowerMessage.includes("fingerprint") || lowerMessage.includes("host") && lowerMessage.includes("denied")) {
113
113
  return new HostVerificationError("SSH host verification failed.", { cause: error });
114
114
  }
115
115
  if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
@@ -183,8 +183,39 @@ async function getLocalPathKind(inputPath) {
183
183
  function normalizeFingerprint(fingerprint) {
184
184
  return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
185
185
  }
186
+ function isHexFingerprint(fingerprint) {
187
+ return /^[0-9a-f]+$/i.test(fingerprint);
188
+ }
189
+ function fingerprintCandidates(fingerprint) {
190
+ const normalized = normalizeFingerprint(fingerprint);
191
+ const candidates = /* @__PURE__ */ new Set([normalized]);
192
+ if (isHexFingerprint(normalized)) {
193
+ candidates.add(normalized.toLowerCase());
194
+ } else {
195
+ try {
196
+ candidates.add(Buffer.from(normalized, "base64").toString("hex"));
197
+ } catch {
198
+ }
199
+ }
200
+ return [...candidates];
201
+ }
186
202
  function isFingerprintMatch(actual, expected) {
187
- return normalizeFingerprint(actual) === normalizeFingerprint(expected);
203
+ const normalizedActual = normalizeFingerprint(actual);
204
+ const normalizedExpected = normalizeFingerprint(expected);
205
+ if (normalizedActual === normalizedExpected) {
206
+ return true;
207
+ }
208
+ return isHexFingerprint(normalizedActual) && isHexFingerprint(normalizedExpected) && normalizedActual.toLowerCase() === normalizedExpected.toLowerCase();
209
+ }
210
+ function hostVerificationHint(host) {
211
+ return [
212
+ "",
213
+ "Typical fix:",
214
+ ` ssh ${host}`,
215
+ "",
216
+ "If you trust the host key, accept it and retry the scp-next command.",
217
+ "Alternatively configure hostFingerprint or knownHostsFile explicitly."
218
+ ].join("\n");
188
219
  }
189
220
  async function resolveAllowedFingerprints(options) {
190
221
  const fingerprints = /* @__PURE__ */ new Set();
@@ -193,7 +224,9 @@ async function resolveAllowedFingerprints(options) {
193
224
  throw new HostVerificationError("A host is required for SSH host verification.");
194
225
  }
195
226
  if (options.hostFingerprint) {
196
- fingerprints.add(normalizeFingerprint(options.hostFingerprint));
227
+ for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
228
+ fingerprints.add(fingerprint);
229
+ }
197
230
  }
198
231
  try {
199
232
  await access2(expandHome(knownHostsFile));
@@ -204,11 +237,15 @@ async function resolveAllowedFingerprints(options) {
204
237
  continue;
205
238
  }
206
239
  const [hosts, algorithm, key] = trimmed.split(/\s+/);
207
- if (!hosts || !algorithm || !key || !hosts.split(",").includes(options.host)) {
240
+ const hostPatterns = hosts?.split(",") ?? [];
241
+ const portHost = options.port ? `[${options.host}]:${options.port}` : void 0;
242
+ const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
243
+ if (!hosts || !algorithm || !key || !matchesHost) {
208
244
  continue;
209
245
  }
210
- const fingerprint = createHash("sha256").update(Buffer.from(key, "base64")).digest("base64").replace(/=+$/g, "");
211
- fingerprints.add(fingerprint);
246
+ const keyBuffer = Buffer.from(key, "base64");
247
+ fingerprints.add(createHash("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
248
+ fingerprints.add(createHash("sha256").update(keyBuffer).digest("hex"));
212
249
  }
213
250
  } catch (error) {
214
251
  if (options.knownHostsFile || !options.hostFingerprint) {
@@ -220,7 +257,9 @@ async function resolveAllowedFingerprints(options) {
220
257
  }
221
258
  if (fingerprints.size === 0) {
222
259
  throw new HostVerificationError(
223
- "SSH host verification failed before connecting: no matching host fingerprint was configured."
260
+ `SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
261
+ options.host
262
+ )}`
224
263
  );
225
264
  }
226
265
  return fingerprints;
@@ -247,7 +286,11 @@ function toRemoteFileInfo(info) {
247
286
  var Ssh2SftpTransport = class {
248
287
  client;
249
288
  constructor(name = "scp-next") {
250
- this.client = new SftpClient(name);
289
+ this.client = new SftpClient(name, {
290
+ error: () => void 0,
291
+ end: () => void 0,
292
+ close: () => void 0
293
+ });
251
294
  }
252
295
  async connect(options) {
253
296
  const allowedFingerprints = await resolveAllowedFingerprints(options);
@@ -277,15 +320,24 @@ var Ssh2SftpTransport = class {
277
320
  } catch (error) {
278
321
  const converted = toScpNextError(error);
279
322
  if (converted instanceof HostVerificationError) {
280
- throw converted;
323
+ throw new HostVerificationError(
324
+ `SSH host verification failed during connection: ${formatErrorMessage(
325
+ error
326
+ )}.${hostVerificationTroubleshooting(options.host)}`,
327
+ { cause: error, context: connectionContext(options) }
328
+ );
329
+ }
330
+ if (isLikelyHostVerificationFailure(error)) {
331
+ throw new HostVerificationError(
332
+ `SSH host verification failed during connection.${hostVerificationTroubleshooting(
333
+ options.host
334
+ )}`,
335
+ { cause: error, context: connectionContext(options) }
336
+ );
281
337
  }
282
- throw new ConnectionError("Unable to connect to SSH server.", {
338
+ throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
283
339
  cause: error,
284
- context: {
285
- host: options.host,
286
- port: options.port,
287
- username: options.username
288
- }
340
+ context: connectionContext(options)
289
341
  });
290
342
  }
291
343
  }
@@ -306,7 +358,7 @@ var Ssh2SftpTransport = class {
306
358
  try {
307
359
  await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
308
360
  } catch (error) {
309
- throw new TransferError(`Upload failed for ${localPath}.`, {
361
+ throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
310
362
  cause: error,
311
363
  context: { localPath, remotePath }
312
364
  });
@@ -316,7 +368,7 @@ var Ssh2SftpTransport = class {
316
368
  try {
317
369
  await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
318
370
  } catch (error) {
319
- throw new TransferError(`Download failed for ${remotePath}.`, {
371
+ throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
320
372
  cause: error,
321
373
  context: { remotePath, localPath }
322
374
  });
@@ -333,6 +385,31 @@ var Ssh2SftpTransport = class {
333
385
  };
334
386
  }
335
387
  };
388
+ function connectionContext(options) {
389
+ return {
390
+ host: options.host,
391
+ port: options.port,
392
+ username: options.username
393
+ };
394
+ }
395
+ function isLikelyHostVerificationFailure(error) {
396
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
397
+ return message.includes("host") && (message.includes("verif") || message.includes("fingerprint") || message.includes("host key") || message.includes("key mismatch") || message.includes("denied"));
398
+ }
399
+ function hostVerificationTroubleshooting(host) {
400
+ const displayHost = host ?? "<host>";
401
+ return [
402
+ "",
403
+ "",
404
+ "Typical fix:",
405
+ ` ssh ${displayHost}`,
406
+ "",
407
+ "If plain ssh works but scp-next fails, check that the host entry is available in",
408
+ "`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
409
+ "OpenSSH usually stores entries as `[host]:port`.",
410
+ "You can also configure hostFingerprint or knownHostsFile explicitly."
411
+ ].join("\n");
412
+ }
336
413
 
337
414
  // src/client/walk.ts
338
415
  import { readdir, stat as stat2 } from "fs/promises";
@@ -560,10 +637,9 @@ var ScpNextClientImpl = class {
560
637
  throw new ValidationError("Uploading a directory requires recursive mode.");
561
638
  }
562
639
  if (localKind === "file") {
563
- await this.ensureRemoteParent(remotePath, options.createDirectories);
564
- await this.assertRemoteOverwrite(remotePath, options.overwrite);
640
+ const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
565
641
  const totalBytes2 = void 0;
566
- await this.transport.uploadFile(resolveLocalPath(options.localPath), remotePath, (step) => {
642
+ await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
567
643
  options.onProgress?.({
568
644
  operation: "upload",
569
645
  source: options.localPath,
@@ -616,6 +692,35 @@ var ScpNextClientImpl = class {
616
692
  completedFiles += 1;
617
693
  }
618
694
  }
695
+ async resolveRemoteDestinationForLocalFile(options, remotePath) {
696
+ const remoteKind = await this.transport.exists(remotePath);
697
+ const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
698
+ if (!isDirectoryLikeDestination) {
699
+ await this.ensureRemoteParent(remotePath, options.createDirectories);
700
+ await this.assertRemoteOverwrite(remotePath, options.overwrite);
701
+ return remotePath;
702
+ }
703
+ if (remoteKind && remoteKind !== "d") {
704
+ throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
705
+ context: { remotePath }
706
+ });
707
+ }
708
+ const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
709
+ if (!remoteKind) {
710
+ if (!options.createDirectories) {
711
+ throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
712
+ context: { remotePath }
713
+ });
714
+ }
715
+ await this.transport.mkdir(remoteDirectory, true);
716
+ }
717
+ const destinationFile = remoteJoin(
718
+ remoteDirectory,
719
+ path4.basename(resolveLocalPath(options.localPath))
720
+ );
721
+ await this.assertRemoteOverwrite(destinationFile, options.overwrite);
722
+ return destinationFile;
723
+ }
619
724
  async downloadWithTransport(options) {
620
725
  const remotePath = normalizeRemotePath(options.remotePath);
621
726
  const remoteKind = await this.transport.exists(remotePath);