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 +125 -20
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +131 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +131 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
fingerprints.add(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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),
|
|
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);
|