scp-next 1.0.3 → 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
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
+ import { readFileSync, realpathSync } from "fs";
5
+ import { dirname, resolve } from "path";
6
+ import { fileURLToPath } from "url";
4
7
  import { Command as Command4, CommanderError } from "commander";
5
8
 
6
9
  // src/client/client.ts
@@ -106,7 +109,7 @@ function toScpNextError(error) {
106
109
  if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
107
110
  return new AuthenticationError("SSH authentication failed.", { cause: error });
108
111
  }
109
- 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")) {
110
113
  return new HostVerificationError("SSH host verification failed.", { cause: error });
111
114
  }
112
115
  if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
@@ -180,8 +183,39 @@ async function getLocalPathKind(inputPath) {
180
183
  function normalizeFingerprint(fingerprint) {
181
184
  return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
182
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
+ }
183
202
  function isFingerprintMatch(actual, expected) {
184
- 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");
185
219
  }
186
220
  async function resolveAllowedFingerprints(options) {
187
221
  const fingerprints = /* @__PURE__ */ new Set();
@@ -190,7 +224,9 @@ async function resolveAllowedFingerprints(options) {
190
224
  throw new HostVerificationError("A host is required for SSH host verification.");
191
225
  }
192
226
  if (options.hostFingerprint) {
193
- fingerprints.add(normalizeFingerprint(options.hostFingerprint));
227
+ for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
228
+ fingerprints.add(fingerprint);
229
+ }
194
230
  }
195
231
  try {
196
232
  await access2(expandHome(knownHostsFile));
@@ -201,11 +237,15 @@ async function resolveAllowedFingerprints(options) {
201
237
  continue;
202
238
  }
203
239
  const [hosts, algorithm, key] = trimmed.split(/\s+/);
204
- 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) {
205
244
  continue;
206
245
  }
207
- const fingerprint = createHash("sha256").update(Buffer.from(key, "base64")).digest("base64").replace(/=+$/g, "");
208
- 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"));
209
249
  }
210
250
  } catch (error) {
211
251
  if (options.knownHostsFile || !options.hostFingerprint) {
@@ -217,7 +257,9 @@ async function resolveAllowedFingerprints(options) {
217
257
  }
218
258
  if (fingerprints.size === 0) {
219
259
  throw new HostVerificationError(
220
- "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
+ )}`
221
263
  );
222
264
  }
223
265
  return fingerprints;
@@ -244,7 +286,11 @@ function toRemoteFileInfo(info) {
244
286
  var Ssh2SftpTransport = class {
245
287
  client;
246
288
  constructor(name = "scp-next") {
247
- this.client = new SftpClient(name);
289
+ this.client = new SftpClient(name, {
290
+ error: () => void 0,
291
+ end: () => void 0,
292
+ close: () => void 0
293
+ });
248
294
  }
249
295
  async connect(options) {
250
296
  const allowedFingerprints = await resolveAllowedFingerprints(options);
@@ -274,15 +320,24 @@ var Ssh2SftpTransport = class {
274
320
  } catch (error) {
275
321
  const converted = toScpNextError(error);
276
322
  if (converted instanceof HostVerificationError) {
277
- 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
+ );
278
329
  }
279
- throw new ConnectionError("Unable to connect to SSH server.", {
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
+ );
337
+ }
338
+ throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
280
339
  cause: error,
281
- context: {
282
- host: options.host,
283
- port: options.port,
284
- username: options.username
285
- }
340
+ context: connectionContext(options)
286
341
  });
287
342
  }
288
343
  }
@@ -303,7 +358,7 @@ var Ssh2SftpTransport = class {
303
358
  try {
304
359
  await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
305
360
  } catch (error) {
306
- throw new TransferError(`Upload failed for ${localPath}.`, {
361
+ throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
307
362
  cause: error,
308
363
  context: { localPath, remotePath }
309
364
  });
@@ -313,7 +368,7 @@ var Ssh2SftpTransport = class {
313
368
  try {
314
369
  await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
315
370
  } catch (error) {
316
- throw new TransferError(`Download failed for ${remotePath}.`, {
371
+ throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
317
372
  cause: error,
318
373
  context: { remotePath, localPath }
319
374
  });
@@ -330,6 +385,31 @@ var Ssh2SftpTransport = class {
330
385
  };
331
386
  }
332
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
+ }
333
413
 
334
414
  // src/client/walk.ts
335
415
  import { readdir, stat as stat2 } from "fs/promises";
@@ -557,10 +637,9 @@ var ScpNextClientImpl = class {
557
637
  throw new ValidationError("Uploading a directory requires recursive mode.");
558
638
  }
559
639
  if (localKind === "file") {
560
- await this.ensureRemoteParent(remotePath, options.createDirectories);
561
- await this.assertRemoteOverwrite(remotePath, options.overwrite);
640
+ const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
562
641
  const totalBytes2 = void 0;
563
- await this.transport.uploadFile(resolveLocalPath(options.localPath), remotePath, (step) => {
642
+ await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
564
643
  options.onProgress?.({
565
644
  operation: "upload",
566
645
  source: options.localPath,
@@ -613,6 +692,35 @@ var ScpNextClientImpl = class {
613
692
  completedFiles += 1;
614
693
  }
615
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
+ }
616
724
  async downloadWithTransport(options) {
617
725
  const remotePath = normalizeRemotePath(options.remotePath);
618
726
  const remoteKind = await this.transport.exists(remotePath);
@@ -1243,12 +1351,32 @@ function createUploadCommand(handlers, output, cwd) {
1243
1351
  }
1244
1352
 
1245
1353
  // src/cli/index.ts
1354
+ function readPackageVersion(currentModuleUrl = import.meta.url) {
1355
+ const currentFile = fileURLToPath(currentModuleUrl);
1356
+ const packageJsonPath = resolve(dirname(currentFile), "..", "..", "package.json");
1357
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
1358
+ return packageJson.version ?? "0.0.0";
1359
+ }
1360
+ function isCliEntrypoint(currentModuleUrl = import.meta.url, argv = process.argv) {
1361
+ const executedFile = argv[1];
1362
+ if (!executedFile) {
1363
+ return false;
1364
+ }
1365
+ try {
1366
+ return realpathSync(fileURLToPath(currentModuleUrl)) === realpathSync(executedFile);
1367
+ } catch {
1368
+ return false;
1369
+ }
1370
+ }
1246
1371
  async function runCli(options = {}) {
1247
1372
  const output = options.output ?? { stdout: process.stdout, stderr: process.stderr };
1248
1373
  const cwd = options.cwd ?? process.cwd();
1249
1374
  const handlers = options.handlers ?? { upload, download };
1250
1375
  const program = new Command4();
1251
- program.name("scp-next").description("Secure file transfers over SSH using SFTP").version("0.1.0").showHelpAfterError().exitOverride();
1376
+ program.name("scp-next").description("Secure file transfers over SSH using SFTP").version(readPackageVersion()).configureOutput({
1377
+ writeOut: (message) => output.stdout.write(message),
1378
+ writeErr: (message) => output.stderr.write(message)
1379
+ }).showHelpAfterError().exitOverride();
1252
1380
  program.addCommand(createUploadCommand(handlers, output, cwd));
1253
1381
  program.addCommand(createDownloadCommand(handlers, output, cwd));
1254
1382
  program.addCommand(createRunCommand(handlers, output, cwd));
@@ -1283,12 +1411,13 @@ async function runCli(options = {}) {
1283
1411
  return 1;
1284
1412
  }
1285
1413
  }
1286
- if (import.meta.url === `file://${process.argv[1]}`) {
1414
+ if (isCliEntrypoint()) {
1287
1415
  void runCli().then((exitCode) => {
1288
1416
  process.exitCode = exitCode;
1289
1417
  });
1290
1418
  }
1291
1419
  export {
1420
+ isCliEntrypoint,
1292
1421
  runCli
1293
1422
  };
1294
1423
  //# sourceMappingURL=index.js.map