scp-next 1.0.0
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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/cli/index.js +1294 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +828 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +180 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +778 -0
- package/dist/index.js.map +1 -0
- package/docs/ARCHITECTURE.md +55 -0
- package/package.json +86 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
// src/client/client.ts
|
|
2
|
+
import { access as access4, mkdir as mkdir2 } from "fs/promises";
|
|
3
|
+
import path4 from "path";
|
|
4
|
+
|
|
5
|
+
// src/client/transport.ts
|
|
6
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
7
|
+
import SftpClient from "ssh2-sftp-client";
|
|
8
|
+
|
|
9
|
+
// src/security/redact.ts
|
|
10
|
+
var SENSITIVE_KEYS = [
|
|
11
|
+
"password",
|
|
12
|
+
"passphrase",
|
|
13
|
+
"privatekey",
|
|
14
|
+
"privatekeyfile",
|
|
15
|
+
"token",
|
|
16
|
+
"authorization",
|
|
17
|
+
"credential",
|
|
18
|
+
"secret"
|
|
19
|
+
];
|
|
20
|
+
var PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
21
|
+
function isSensitiveKey(key) {
|
|
22
|
+
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
23
|
+
return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
|
|
24
|
+
}
|
|
25
|
+
function redactSensitiveValues(value) {
|
|
26
|
+
if (typeof value === "string") {
|
|
27
|
+
return value.replace(PRIVATE_KEY_PATTERN, "[REDACTED]");
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
const redacted = value.map((item) => redactSensitiveValues(item));
|
|
31
|
+
return redacted;
|
|
32
|
+
}
|
|
33
|
+
if (value && typeof value === "object") {
|
|
34
|
+
const redacted = {};
|
|
35
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
36
|
+
redacted[key] = isSensitiveKey(key) ? "[REDACTED]" : redactSensitiveValues(nestedValue);
|
|
37
|
+
}
|
|
38
|
+
return redacted;
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/errors/index.ts
|
|
44
|
+
var ScpNextError = class extends Error {
|
|
45
|
+
code;
|
|
46
|
+
context;
|
|
47
|
+
constructor(message, options = {}) {
|
|
48
|
+
super(message, { cause: options.cause });
|
|
49
|
+
this.name = new.target.name;
|
|
50
|
+
this.code = options.code ?? "SCP_NEXT_ERROR";
|
|
51
|
+
this.context = redactSensitiveValues(options.context ?? {});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var ConfigurationError = class extends ScpNextError {
|
|
55
|
+
constructor(message, options = {}) {
|
|
56
|
+
super(message, { ...options, code: "SCP_NEXT_CONFIGURATION_ERROR" });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var ValidationError = class extends ScpNextError {
|
|
60
|
+
constructor(message, options = {}) {
|
|
61
|
+
super(message, { ...options, code: "SCP_NEXT_VALIDATION_ERROR" });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var AuthenticationError = class extends ScpNextError {
|
|
65
|
+
constructor(message, options = {}) {
|
|
66
|
+
super(message, { ...options, code: "SCP_NEXT_AUTHENTICATION_ERROR" });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var ConnectionError = class extends ScpNextError {
|
|
70
|
+
constructor(message, options = {}) {
|
|
71
|
+
super(message, { ...options, code: "SCP_NEXT_CONNECTION_ERROR" });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var TransferError = class extends ScpNextError {
|
|
75
|
+
constructor(message, options = {}) {
|
|
76
|
+
super(message, { ...options, code: "SCP_NEXT_TRANSFER_ERROR" });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var FileSystemError = class extends ScpNextError {
|
|
80
|
+
constructor(message, options = {}) {
|
|
81
|
+
super(message, { ...options, code: "SCP_NEXT_FILESYSTEM_ERROR" });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var HostVerificationError = class extends ScpNextError {
|
|
85
|
+
constructor(message, options = {}) {
|
|
86
|
+
super(message, { ...options, code: "SCP_NEXT_HOST_VERIFICATION_ERROR" });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
function toScpNextError(error) {
|
|
90
|
+
if (error instanceof ScpNextError) {
|
|
91
|
+
return error;
|
|
92
|
+
}
|
|
93
|
+
const message = error instanceof Error ? error.message : "Unexpected scp-next error.";
|
|
94
|
+
const lowerMessage = message.toLowerCase();
|
|
95
|
+
if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
|
|
96
|
+
return new AuthenticationError("SSH authentication failed.", { cause: error });
|
|
97
|
+
}
|
|
98
|
+
if (lowerMessage.includes("host verification") || lowerMessage.includes("host key")) {
|
|
99
|
+
return new HostVerificationError("SSH host verification failed.", { cause: error });
|
|
100
|
+
}
|
|
101
|
+
if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
|
|
102
|
+
return new ConnectionError("SSH connection failed.", { cause: error });
|
|
103
|
+
}
|
|
104
|
+
return new TransferError(String(redactSensitiveValues(message)), { cause: error });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/security/host-verification.ts
|
|
108
|
+
import { createHash } from "crypto";
|
|
109
|
+
import { access as access2, readFile } from "fs/promises";
|
|
110
|
+
|
|
111
|
+
// src/paths/local-path.ts
|
|
112
|
+
import { constants } from "fs";
|
|
113
|
+
import { access, mkdir, stat } from "fs/promises";
|
|
114
|
+
import os from "os";
|
|
115
|
+
import path from "path";
|
|
116
|
+
function expandHome(inputPath) {
|
|
117
|
+
if (inputPath === "~") {
|
|
118
|
+
return os.homedir();
|
|
119
|
+
}
|
|
120
|
+
if (inputPath.startsWith(`~${path.sep}`) || inputPath.startsWith("~/")) {
|
|
121
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
122
|
+
}
|
|
123
|
+
return inputPath;
|
|
124
|
+
}
|
|
125
|
+
function resolveLocalPath(inputPath, baseDir = process.cwd()) {
|
|
126
|
+
const expanded = expandHome(inputPath);
|
|
127
|
+
return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(baseDir, expanded);
|
|
128
|
+
}
|
|
129
|
+
async function assertLocalPathExists(inputPath) {
|
|
130
|
+
try {
|
|
131
|
+
await access(resolveLocalPath(inputPath), constants.F_OK);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new FileSystemError(`Local path does not exist: ${inputPath}`, {
|
|
134
|
+
cause: error,
|
|
135
|
+
context: { path: inputPath }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function assertDownloadDestination(inputPath, createDirectories = false) {
|
|
140
|
+
const resolved = resolveLocalPath(inputPath);
|
|
141
|
+
const parent = path.dirname(resolved);
|
|
142
|
+
if (createDirectories) {
|
|
143
|
+
await mkdir(parent, { recursive: true });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await access(parent, constants.W_OK);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new FileSystemError(`Local destination parent does not exist or is not writable: ${parent}`, {
|
|
150
|
+
cause: error,
|
|
151
|
+
context: { path: inputPath }
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function getLocalPathKind(inputPath) {
|
|
156
|
+
const stats = await stat(resolveLocalPath(inputPath));
|
|
157
|
+
if (stats.isDirectory()) {
|
|
158
|
+
return "directory";
|
|
159
|
+
}
|
|
160
|
+
if (stats.isFile()) {
|
|
161
|
+
return "file";
|
|
162
|
+
}
|
|
163
|
+
throw new ValidationError(`Local path must be a file or directory: ${inputPath}`, {
|
|
164
|
+
context: { path: inputPath }
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/security/host-verification.ts
|
|
169
|
+
function normalizeFingerprint(fingerprint) {
|
|
170
|
+
return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
|
|
171
|
+
}
|
|
172
|
+
function isFingerprintMatch(actual, expected) {
|
|
173
|
+
return normalizeFingerprint(actual) === normalizeFingerprint(expected);
|
|
174
|
+
}
|
|
175
|
+
async function resolveAllowedFingerprints(options) {
|
|
176
|
+
const fingerprints = /* @__PURE__ */ new Set();
|
|
177
|
+
const knownHostsFile = options.knownHostsFile ?? "~/.ssh/known_hosts";
|
|
178
|
+
if (!options.host) {
|
|
179
|
+
throw new HostVerificationError("A host is required for SSH host verification.");
|
|
180
|
+
}
|
|
181
|
+
if (options.hostFingerprint) {
|
|
182
|
+
fingerprints.add(normalizeFingerprint(options.hostFingerprint));
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await access2(expandHome(knownHostsFile));
|
|
186
|
+
const content = await readFile(expandHome(knownHostsFile), "utf8");
|
|
187
|
+
for (const line of content.split(/\r?\n/)) {
|
|
188
|
+
const trimmed = line.trim();
|
|
189
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("@")) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const [hosts, algorithm, key] = trimmed.split(/\s+/);
|
|
193
|
+
if (!hosts || !algorithm || !key || !hosts.split(",").includes(options.host)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const fingerprint = createHash("sha256").update(Buffer.from(key, "base64")).digest("base64").replace(/=+$/g, "");
|
|
197
|
+
fingerprints.add(fingerprint);
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (options.knownHostsFile || !options.hostFingerprint) {
|
|
201
|
+
throw new HostVerificationError(
|
|
202
|
+
"SSH host verification requires hostFingerprint or an accessible knownHostsFile.",
|
|
203
|
+
{ cause: error, context: { knownHostsFile } }
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (fingerprints.size === 0) {
|
|
208
|
+
throw new HostVerificationError(
|
|
209
|
+
"SSH host verification failed before connecting: no matching host fingerprint was configured."
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return fingerprints;
|
|
213
|
+
}
|
|
214
|
+
function createHostVerifier(allowedFingerprints) {
|
|
215
|
+
return (hashedKey) => {
|
|
216
|
+
for (const expected of allowedFingerprints) {
|
|
217
|
+
if (isFingerprintMatch(hashedKey, expected)) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/client/transport.ts
|
|
226
|
+
function toRemoteFileInfo(info) {
|
|
227
|
+
return {
|
|
228
|
+
type: info.type,
|
|
229
|
+
name: info.name,
|
|
230
|
+
size: info.size
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
var Ssh2SftpTransport = class {
|
|
234
|
+
client;
|
|
235
|
+
constructor(name = "scp-next") {
|
|
236
|
+
this.client = new SftpClient(name);
|
|
237
|
+
}
|
|
238
|
+
async connect(options) {
|
|
239
|
+
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
240
|
+
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
241
|
+
const connectOptions = {};
|
|
242
|
+
if (options.host !== void 0) connectOptions.host = options.host;
|
|
243
|
+
if (options.port !== void 0) connectOptions.port = options.port;
|
|
244
|
+
if (options.username !== void 0) connectOptions.username = options.username;
|
|
245
|
+
if (options.password !== void 0) connectOptions.password = options.password;
|
|
246
|
+
if (options.privateKey !== void 0) {
|
|
247
|
+
connectOptions.privateKey = options.privateKey;
|
|
248
|
+
} else if (options.privateKeyFile) {
|
|
249
|
+
connectOptions.privateKey = await readFile2(expandHome(options.privateKeyFile), "utf8");
|
|
250
|
+
}
|
|
251
|
+
if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
|
|
252
|
+
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
253
|
+
if (agent !== void 0) {
|
|
254
|
+
connectOptions.agent = agent;
|
|
255
|
+
}
|
|
256
|
+
if (options.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
|
|
257
|
+
if (hostVerifier) {
|
|
258
|
+
connectOptions.hostHash = "sha256";
|
|
259
|
+
connectOptions.hostVerifier = hostVerifier;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
await this.client.connect(connectOptions);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const converted = toScpNextError(error);
|
|
265
|
+
if (converted instanceof HostVerificationError) {
|
|
266
|
+
throw converted;
|
|
267
|
+
}
|
|
268
|
+
throw new ConnectionError("Unable to connect to SSH server.", {
|
|
269
|
+
cause: error,
|
|
270
|
+
context: {
|
|
271
|
+
host: options.host,
|
|
272
|
+
port: options.port,
|
|
273
|
+
username: options.username
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async close() {
|
|
279
|
+
await this.client.end();
|
|
280
|
+
}
|
|
281
|
+
async exists(remotePath) {
|
|
282
|
+
return this.client.exists(remotePath);
|
|
283
|
+
}
|
|
284
|
+
async mkdir(remotePath, recursive = true) {
|
|
285
|
+
await this.client.mkdir(remotePath, recursive);
|
|
286
|
+
}
|
|
287
|
+
async list(remotePath) {
|
|
288
|
+
const files = await this.client.list(remotePath);
|
|
289
|
+
return files.map(toRemoteFileInfo);
|
|
290
|
+
}
|
|
291
|
+
async uploadFile(localPath, remotePath, onStep) {
|
|
292
|
+
try {
|
|
293
|
+
await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw new TransferError(`Upload failed for ${localPath}.`, {
|
|
296
|
+
cause: error,
|
|
297
|
+
context: { localPath, remotePath }
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async downloadFile(remotePath, localPath, onStep) {
|
|
302
|
+
try {
|
|
303
|
+
await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
|
|
304
|
+
} catch (error) {
|
|
305
|
+
throw new TransferError(`Download failed for ${remotePath}.`, {
|
|
306
|
+
cause: error,
|
|
307
|
+
context: { remotePath, localPath }
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
transferOptions(onStep) {
|
|
312
|
+
if (!onStep) {
|
|
313
|
+
return void 0;
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
step(transferredBytes, chunkBytes, totalBytes) {
|
|
317
|
+
onStep({ transferredBytes, chunkBytes, totalBytes });
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// src/client/walk.ts
|
|
324
|
+
import { readdir, stat as stat2 } from "fs/promises";
|
|
325
|
+
import path3 from "path";
|
|
326
|
+
|
|
327
|
+
// src/paths/remote-path.ts
|
|
328
|
+
import path2 from "path";
|
|
329
|
+
function assertRemotePath(inputPath, label = "remote path") {
|
|
330
|
+
if (!inputPath || inputPath.trim().length === 0) {
|
|
331
|
+
throw new ValidationError(`Invalid ${label}: remote paths must not be empty.`, {
|
|
332
|
+
context: { path: inputPath }
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function normalizeRemotePath(inputPath) {
|
|
337
|
+
assertRemotePath(inputPath);
|
|
338
|
+
return path2.posix.normalize(inputPath.replace(/\\/g, "/"));
|
|
339
|
+
}
|
|
340
|
+
function remoteJoin(...parts) {
|
|
341
|
+
const joined = path2.posix.join(...parts);
|
|
342
|
+
assertRemotePath(joined);
|
|
343
|
+
return joined;
|
|
344
|
+
}
|
|
345
|
+
function remoteDirname(inputPath) {
|
|
346
|
+
assertRemotePath(inputPath);
|
|
347
|
+
return path2.posix.dirname(inputPath);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/client/walk.ts
|
|
351
|
+
async function walkLocalFiles(rootPath) {
|
|
352
|
+
const resolvedRoot = resolveLocalPath(rootPath);
|
|
353
|
+
const stats = await stat2(resolvedRoot);
|
|
354
|
+
if (stats.isFile()) {
|
|
355
|
+
return [
|
|
356
|
+
{
|
|
357
|
+
absolutePath: resolvedRoot,
|
|
358
|
+
relativePath: path3.basename(resolvedRoot),
|
|
359
|
+
size: stats.size
|
|
360
|
+
}
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
const files = [];
|
|
364
|
+
async function visit(directory) {
|
|
365
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
366
|
+
for (const entry of entries) {
|
|
367
|
+
const absolutePath = path3.join(directory, entry.name);
|
|
368
|
+
if (entry.isDirectory()) {
|
|
369
|
+
await visit(absolutePath);
|
|
370
|
+
} else if (entry.isFile()) {
|
|
371
|
+
const entryStats = await stat2(absolutePath);
|
|
372
|
+
files.push({
|
|
373
|
+
absolutePath,
|
|
374
|
+
relativePath: path3.relative(resolvedRoot, absolutePath),
|
|
375
|
+
size: entryStats.size
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
await visit(resolvedRoot);
|
|
381
|
+
return files;
|
|
382
|
+
}
|
|
383
|
+
async function walkRemoteFiles(transport, rootPath) {
|
|
384
|
+
const rootEntries = await transport.list(rootPath);
|
|
385
|
+
const files = [];
|
|
386
|
+
async function visit(directory, relativeBase, entries) {
|
|
387
|
+
for (const entry of entries) {
|
|
388
|
+
const fullPath = remoteJoin(directory, entry.name);
|
|
389
|
+
const relativePath = relativeBase ? remoteJoin(relativeBase, entry.name) : entry.name;
|
|
390
|
+
if (entry.type === "d") {
|
|
391
|
+
await visit(fullPath, relativePath, await transport.list(fullPath));
|
|
392
|
+
} else if (entry.type === "-") {
|
|
393
|
+
files.push({
|
|
394
|
+
remotePath: fullPath,
|
|
395
|
+
relativePath,
|
|
396
|
+
size: entry.size
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
await visit(rootPath, "", rootEntries);
|
|
402
|
+
return files;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/config/validation.ts
|
|
406
|
+
import { access as access3 } from "fs/promises";
|
|
407
|
+
function validatePort(port) {
|
|
408
|
+
if (port === void 0) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
412
|
+
throw new ValidationError("Port must be an integer between 1 and 65535.", {
|
|
413
|
+
context: { port }
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function validateTimeout(timeout) {
|
|
418
|
+
if (timeout === void 0) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
422
|
+
throw new ValidationError("Timeout must be a positive number.", {
|
|
423
|
+
context: { timeout }
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function validateServerOptions(options) {
|
|
428
|
+
if (!options.host) {
|
|
429
|
+
throw new ValidationError("Host is required.");
|
|
430
|
+
}
|
|
431
|
+
if (!options.username) {
|
|
432
|
+
throw new ValidationError("Username is required.");
|
|
433
|
+
}
|
|
434
|
+
validatePort(options.port);
|
|
435
|
+
validateTimeout(options.timeout);
|
|
436
|
+
if (options.privateKeyFile) {
|
|
437
|
+
try {
|
|
438
|
+
await access3(resolveLocalPath(options.privateKeyFile));
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw new ValidationError(`Private key file does not exist: ${options.privateKeyFile}`, {
|
|
441
|
+
cause: error,
|
|
442
|
+
context: { privateKeyFile: options.privateKeyFile }
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const hasAuthentication = Boolean(options.password) || Boolean(options.privateKey) || Boolean(options.privateKeyFile) || Boolean(options.agent) || Boolean(process.env.SSH_AUTH_SOCK);
|
|
447
|
+
if (!hasAuthentication) {
|
|
448
|
+
throw new ValidationError(
|
|
449
|
+
"Authentication is required. Provide a password, private key, private key file, or SSH agent."
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async function validateUploadOptions(options) {
|
|
454
|
+
if (!options.localPath) {
|
|
455
|
+
throw new ValidationError("Missing localPath.");
|
|
456
|
+
}
|
|
457
|
+
if (!options.remotePath) {
|
|
458
|
+
throw new ValidationError("Missing remotePath.");
|
|
459
|
+
}
|
|
460
|
+
assertRemotePath(options.remotePath, "remotePath");
|
|
461
|
+
validateTimeout(options.timeout);
|
|
462
|
+
await assertLocalPathExists(options.localPath);
|
|
463
|
+
if (!options.dryRun) {
|
|
464
|
+
await validateServerOptions(options);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function validateDownloadOptions(options) {
|
|
468
|
+
if (!options.remotePath) {
|
|
469
|
+
throw new ValidationError("Missing remotePath.");
|
|
470
|
+
}
|
|
471
|
+
if (!options.localPath) {
|
|
472
|
+
throw new ValidationError("Missing localPath.");
|
|
473
|
+
}
|
|
474
|
+
assertRemotePath(options.remotePath, "remotePath");
|
|
475
|
+
validateTimeout(options.timeout);
|
|
476
|
+
await assertDownloadDestination(options.localPath, options.createDirectories);
|
|
477
|
+
if (!options.dryRun) {
|
|
478
|
+
await validateServerOptions(options);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/client/client.ts
|
|
483
|
+
function percentage(transferredBytes, totalBytes) {
|
|
484
|
+
if (!totalBytes || totalBytes <= 0) {
|
|
485
|
+
return void 0;
|
|
486
|
+
}
|
|
487
|
+
return Math.min(100, Math.round(transferredBytes / totalBytes * 100));
|
|
488
|
+
}
|
|
489
|
+
function mergeOptions(serverOptions, transferOptions) {
|
|
490
|
+
return { ...serverOptions, ...transferOptions ?? {} };
|
|
491
|
+
}
|
|
492
|
+
var ScpNextClientImpl = class {
|
|
493
|
+
serverOptions;
|
|
494
|
+
transport;
|
|
495
|
+
connected = false;
|
|
496
|
+
constructor(serverOptions, dependencies = {}) {
|
|
497
|
+
this.serverOptions = serverOptions;
|
|
498
|
+
this.transport = dependencies.transport ?? new Ssh2SftpTransport();
|
|
499
|
+
}
|
|
500
|
+
async connect() {
|
|
501
|
+
await validateServerOptions(this.serverOptions);
|
|
502
|
+
await this.transport.connect(this.serverOptions);
|
|
503
|
+
this.connected = true;
|
|
504
|
+
}
|
|
505
|
+
async upload(localPath, remotePath, options = {}) {
|
|
506
|
+
const uploadOptions = {
|
|
507
|
+
...mergeOptions(this.serverOptions, options),
|
|
508
|
+
localPath,
|
|
509
|
+
remotePath
|
|
510
|
+
};
|
|
511
|
+
await validateUploadOptions(uploadOptions);
|
|
512
|
+
if (options.dryRun) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await this.ensureConnected();
|
|
516
|
+
await this.uploadWithTransport(uploadOptions);
|
|
517
|
+
}
|
|
518
|
+
async download(remotePath, localPath, options = {}) {
|
|
519
|
+
const downloadOptions = {
|
|
520
|
+
...mergeOptions(this.serverOptions, options),
|
|
521
|
+
remotePath,
|
|
522
|
+
localPath
|
|
523
|
+
};
|
|
524
|
+
await validateDownloadOptions(downloadOptions);
|
|
525
|
+
if (options.dryRun) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
await this.ensureConnected();
|
|
529
|
+
await this.downloadWithTransport(downloadOptions);
|
|
530
|
+
}
|
|
531
|
+
async close() {
|
|
532
|
+
if (this.connected) {
|
|
533
|
+
await this.transport.close();
|
|
534
|
+
this.connected = false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async ensureConnected() {
|
|
538
|
+
if (!this.connected) {
|
|
539
|
+
await this.connect();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async uploadWithTransport(options) {
|
|
543
|
+
const localKind = await getLocalPathKind(options.localPath);
|
|
544
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
545
|
+
if (localKind === "directory" && !options.recursive) {
|
|
546
|
+
throw new ValidationError("Uploading a directory requires recursive mode.");
|
|
547
|
+
}
|
|
548
|
+
if (localKind === "file") {
|
|
549
|
+
await this.ensureRemoteParent(remotePath, options.createDirectories);
|
|
550
|
+
await this.assertRemoteOverwrite(remotePath, options.overwrite);
|
|
551
|
+
const totalBytes2 = void 0;
|
|
552
|
+
await this.transport.uploadFile(resolveLocalPath(options.localPath), remotePath, (step) => {
|
|
553
|
+
options.onProgress?.({
|
|
554
|
+
operation: "upload",
|
|
555
|
+
source: options.localPath,
|
|
556
|
+
destination: options.remotePath,
|
|
557
|
+
transferredBytes: step.transferredBytes,
|
|
558
|
+
totalBytes: step.totalBytes || totalBytes2,
|
|
559
|
+
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
560
|
+
currentFile: options.localPath
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const files = await walkLocalFiles(options.localPath);
|
|
566
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
567
|
+
let completedBytes = 0;
|
|
568
|
+
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
|
+
}
|
|
583
|
+
for (const file of files) {
|
|
584
|
+
const destinationFile = remoteJoin(remotePath, file.relativePath.split(path4.sep).join("/"));
|
|
585
|
+
await this.ensureRemoteParent(destinationFile, true);
|
|
586
|
+
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
587
|
+
await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
|
|
588
|
+
const transferredBytes = completedBytes + step.transferredBytes;
|
|
589
|
+
options.onProgress?.({
|
|
590
|
+
operation: "upload",
|
|
591
|
+
source: options.localPath,
|
|
592
|
+
destination: options.remotePath,
|
|
593
|
+
transferredBytes,
|
|
594
|
+
totalBytes,
|
|
595
|
+
percentage: percentage(transferredBytes, totalBytes),
|
|
596
|
+
currentFile: file.relativePath,
|
|
597
|
+
completedFiles,
|
|
598
|
+
totalFiles: files.length
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
completedBytes += file.size;
|
|
602
|
+
completedFiles += 1;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async downloadWithTransport(options) {
|
|
606
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
607
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
608
|
+
if (!remoteKind) {
|
|
609
|
+
throw new TransferError(`Remote path does not exist: ${options.remotePath}`, {
|
|
610
|
+
context: { remotePath: options.remotePath }
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (remoteKind === "d") {
|
|
614
|
+
if (!options.recursive) {
|
|
615
|
+
throw new ValidationError("Downloading a directory requires recursive mode.");
|
|
616
|
+
}
|
|
617
|
+
await this.downloadDirectory(options);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
await this.downloadSingleFile(options, remotePath);
|
|
621
|
+
}
|
|
622
|
+
async downloadSingleFile(options, remotePath) {
|
|
623
|
+
const localPath = resolveLocalPath(options.localPath);
|
|
624
|
+
await this.assertLocalOverwrite(localPath, options.overwrite);
|
|
625
|
+
if (options.createDirectories) {
|
|
626
|
+
await mkdir2(path4.dirname(localPath), { recursive: true });
|
|
627
|
+
}
|
|
628
|
+
await this.transport.downloadFile(remotePath, localPath, (step) => {
|
|
629
|
+
options.onProgress?.({
|
|
630
|
+
operation: "download",
|
|
631
|
+
source: options.remotePath,
|
|
632
|
+
destination: options.localPath,
|
|
633
|
+
transferredBytes: step.transferredBytes,
|
|
634
|
+
totalBytes: step.totalBytes,
|
|
635
|
+
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
636
|
+
currentFile: options.remotePath
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async downloadDirectory(options) {
|
|
641
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
642
|
+
const localRoot = resolveLocalPath(options.localPath);
|
|
643
|
+
const files = await walkRemoteFiles(this.transport, remotePath);
|
|
644
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
645
|
+
let completedBytes = 0;
|
|
646
|
+
let completedFiles = 0;
|
|
647
|
+
await mkdir2(localRoot, { recursive: Boolean(options.createDirectories) });
|
|
648
|
+
for (const file of files) {
|
|
649
|
+
const localFile = path4.join(localRoot, ...file.relativePath.split("/"));
|
|
650
|
+
await this.assertLocalOverwrite(localFile, options.overwrite);
|
|
651
|
+
await mkdir2(path4.dirname(localFile), { recursive: true });
|
|
652
|
+
await this.transport.downloadFile(file.remotePath, localFile, (step) => {
|
|
653
|
+
const transferredBytes = completedBytes + step.transferredBytes;
|
|
654
|
+
options.onProgress?.({
|
|
655
|
+
operation: "download",
|
|
656
|
+
source: options.remotePath,
|
|
657
|
+
destination: options.localPath,
|
|
658
|
+
transferredBytes,
|
|
659
|
+
totalBytes,
|
|
660
|
+
percentage: percentage(transferredBytes, totalBytes),
|
|
661
|
+
currentFile: file.relativePath,
|
|
662
|
+
completedFiles,
|
|
663
|
+
totalFiles: files.length
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
completedBytes += file.size;
|
|
667
|
+
completedFiles += 1;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async ensureRemoteParent(remotePath, createDirectories = false) {
|
|
671
|
+
const parent = remoteDirname(remotePath);
|
|
672
|
+
if (parent === "." || parent === "/") {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (createDirectories) {
|
|
676
|
+
await this.transport.mkdir(parent, true);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const exists = await this.transport.exists(parent);
|
|
680
|
+
if (exists !== "d") {
|
|
681
|
+
throw new TransferError(`Remote destination parent does not exist: ${parent}`, {
|
|
682
|
+
context: { remotePath }
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async assertRemoteOverwrite(remotePath, overwrite = false) {
|
|
687
|
+
if (overwrite) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const exists = await this.transport.exists(remotePath);
|
|
691
|
+
if (exists) {
|
|
692
|
+
throw new TransferError(`Remote destination already exists: ${remotePath}`, {
|
|
693
|
+
context: { remotePath }
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async assertLocalOverwrite(localPath, overwrite = false) {
|
|
698
|
+
if (overwrite) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
await access4(localPath);
|
|
703
|
+
throw new TransferError(`Local destination already exists: ${localPath}`, {
|
|
704
|
+
context: { localPath }
|
|
705
|
+
});
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (error instanceof TransferError) {
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
function createClient(options) {
|
|
714
|
+
return new ScpNextClientImpl(options);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/client/upload.ts
|
|
718
|
+
async function upload(options) {
|
|
719
|
+
const client = new ScpNextClientImpl(options);
|
|
720
|
+
try {
|
|
721
|
+
await client.upload(options.localPath, options.remotePath, options);
|
|
722
|
+
} finally {
|
|
723
|
+
await client.close();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/client/download.ts
|
|
728
|
+
async function download(options) {
|
|
729
|
+
const client = new ScpNextClientImpl(options);
|
|
730
|
+
try {
|
|
731
|
+
await client.download(options.remotePath, options.localPath, options);
|
|
732
|
+
} finally {
|
|
733
|
+
await client.close();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/client/copy.ts
|
|
738
|
+
async function copy(options) {
|
|
739
|
+
if (options.source.type === "local" && options.destination.type === "remote") {
|
|
740
|
+
await upload({
|
|
741
|
+
...options,
|
|
742
|
+
localPath: options.source.path,
|
|
743
|
+
remotePath: options.destination.path
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (options.source.type === "remote" && options.destination.type === "local") {
|
|
748
|
+
await download({
|
|
749
|
+
...options,
|
|
750
|
+
remotePath: options.source.path,
|
|
751
|
+
localPath: options.destination.path
|
|
752
|
+
});
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
throw new ValidationError("copy() supports only local-to-remote or remote-to-local transfers.", {
|
|
756
|
+
context: {
|
|
757
|
+
sourceType: options.source.type,
|
|
758
|
+
destinationType: options.destination.type
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
export {
|
|
763
|
+
AuthenticationError,
|
|
764
|
+
ConfigurationError,
|
|
765
|
+
ConnectionError,
|
|
766
|
+
FileSystemError,
|
|
767
|
+
HostVerificationError,
|
|
768
|
+
ScpNextClientImpl,
|
|
769
|
+
ScpNextError,
|
|
770
|
+
TransferError,
|
|
771
|
+
ValidationError,
|
|
772
|
+
copy,
|
|
773
|
+
createClient,
|
|
774
|
+
download,
|
|
775
|
+
toScpNextError,
|
|
776
|
+
upload
|
|
777
|
+
};
|
|
778
|
+
//# sourceMappingURL=index.js.map
|