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 +151 -22
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
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"));
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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),
|
|
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(
|
|
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 (
|
|
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
|