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