scp-next 1.1.2 → 1.1.3
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/README.md +29 -25
- package/dist/cli/index.js +1697 -1744
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +1177 -1260
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1162 -1210
- package/dist/index.js.map +1 -1
- package/guides/ARCHITECTURE.md +17 -1
- package/package.json +39 -12
package/dist/index.cjs
CHANGED
|
@@ -1,1405 +1,1322 @@
|
|
|
1
|
-
|
|
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);
|
|
1
|
+
'use strict';
|
|
29
2
|
|
|
30
|
-
|
|
31
|
-
var
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
HostVerificationError: () => HostVerificationError,
|
|
38
|
-
RemoteCommandError: () => RemoteCommandError,
|
|
39
|
-
ScpNextClientImpl: () => ScpNextClientImpl,
|
|
40
|
-
ScpNextError: () => ScpNextError,
|
|
41
|
-
TransferError: () => TransferError,
|
|
42
|
-
ValidationError: () => ValidationError,
|
|
43
|
-
copy: () => copy,
|
|
44
|
-
createClient: () => createClient,
|
|
45
|
-
download: () => download,
|
|
46
|
-
toScpNextError: () => toScpNextError,
|
|
47
|
-
upload: () => upload
|
|
48
|
-
});
|
|
49
|
-
module.exports = __toCommonJS(src_exports);
|
|
3
|
+
var promises = require('node:fs/promises');
|
|
4
|
+
var path = require('node:path');
|
|
5
|
+
var SftpClient = require('ssh2-sftp-client');
|
|
6
|
+
var node_crypto = require('node:crypto');
|
|
7
|
+
var node_fs = require('node:fs');
|
|
8
|
+
var os = require('node:os');
|
|
9
|
+
var ssh2 = require('ssh2');
|
|
50
10
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"password",
|
|
61
|
-
"passphrase",
|
|
62
|
-
"privatekey",
|
|
63
|
-
"privatekeyfile",
|
|
64
|
-
"token",
|
|
65
|
-
"authorization",
|
|
66
|
-
"credential",
|
|
67
|
-
"secret"
|
|
11
|
+
const SENSITIVE_KEYS = [
|
|
12
|
+
"password",
|
|
13
|
+
"passphrase",
|
|
14
|
+
"privatekey",
|
|
15
|
+
"privatekeyfile",
|
|
16
|
+
"token",
|
|
17
|
+
"authorization",
|
|
18
|
+
"credential",
|
|
19
|
+
"secret"
|
|
68
20
|
];
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
21
|
+
const PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
22
|
+
const SECRET_OPTION_PATTERN = /(--(?:password|passphrase|token|secret|authorization|credential)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi;
|
|
23
|
+
const SECRET_ASSIGNMENT_PATTERN = /(\b(?:password|passphrase|token|secret|authorization|credential)[A-Z0-9_-]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s&]+)/gi;
|
|
72
24
|
function isSensitiveKey(key) {
|
|
73
|
-
|
|
74
|
-
|
|
25
|
+
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
26
|
+
return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
|
|
75
27
|
}
|
|
76
28
|
function redactSensitiveValues(value) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
return value.replace(PRIVATE_KEY_PATTERN, "[REDACTED]");
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
const redacted = value.map((item) => redactSensitiveValues(item));
|
|
34
|
+
return redacted;
|
|
35
|
+
}
|
|
36
|
+
if (value && typeof value === "object") {
|
|
37
|
+
const redacted = {};
|
|
38
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
39
|
+
redacted[key] = isSensitiveKey(key) ? "[REDACTED]" : redactSensitiveValues(nestedValue);
|
|
40
|
+
}
|
|
41
|
+
return redacted;
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
92
44
|
}
|
|
93
45
|
function formatErrorMessage(error) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
46
|
+
if (error instanceof Error) {
|
|
47
|
+
return redactKnownSensitiveValues(error.message, {});
|
|
48
|
+
}
|
|
49
|
+
return redactKnownSensitiveValues(String(error), {});
|
|
98
50
|
}
|
|
99
51
|
function redactKnownSensitiveValues(text, source) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return redactedKnownValues.replace(SECRET_OPTION_PATTERN, "$1[REDACTED]").replace(SECRET_ASSIGNMENT_PATTERN, "$1[REDACTED]");
|
|
52
|
+
const sensitiveValues = collectSensitiveValues(source);
|
|
53
|
+
const redactedKnownValues = sensitiveValues.reduce((redacted, value) => redacted.split(value).join("[REDACTED]"), String(redactSensitiveValues(text)));
|
|
54
|
+
return redactedKnownValues
|
|
55
|
+
.replace(SECRET_OPTION_PATTERN, "$1[REDACTED]")
|
|
56
|
+
.replace(SECRET_ASSIGNMENT_PATTERN, "$1[REDACTED]");
|
|
106
57
|
}
|
|
107
58
|
function collectSensitiveValues(value, key = "") {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
([nestedKey, nestedValue]) => collectSensitiveValues(nestedValue, nestedKey)
|
|
122
|
-
);
|
|
59
|
+
if (isSensitiveKey(key)) {
|
|
60
|
+
if (typeof value === "string" && value) {
|
|
61
|
+
return [value];
|
|
62
|
+
}
|
|
63
|
+
if (Buffer.isBuffer(value) && value.length > 0) {
|
|
64
|
+
return [value.toString("utf8")];
|
|
65
|
+
}
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
if (!value || typeof value !== "object") {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
return Object.entries(value).flatMap(([nestedKey, nestedValue]) => collectSensitiveValues(nestedValue, nestedKey));
|
|
123
72
|
}
|
|
124
73
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
};
|
|
74
|
+
class ScpNextError extends Error {
|
|
75
|
+
code;
|
|
76
|
+
context;
|
|
77
|
+
constructor(message, options = {}) {
|
|
78
|
+
super(message, { cause: options.cause });
|
|
79
|
+
this.name = new.target.name;
|
|
80
|
+
this.code = options.code ?? "SCP_NEXT_ERROR";
|
|
81
|
+
this.context = redactSensitiveValues(options.context ?? {});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
class ConfigurationError extends ScpNextError {
|
|
85
|
+
constructor(message, options = {}) {
|
|
86
|
+
super(message, { ...options, code: "SCP_NEXT_CONFIGURATION_ERROR" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
class ValidationError extends ScpNextError {
|
|
90
|
+
constructor(message, options = {}) {
|
|
91
|
+
super(message, { ...options, code: "SCP_NEXT_VALIDATION_ERROR" });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
class AuthenticationError extends ScpNextError {
|
|
95
|
+
constructor(message, options = {}) {
|
|
96
|
+
super(message, { ...options, code: "SCP_NEXT_AUTHENTICATION_ERROR" });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
class ConnectionError extends ScpNextError {
|
|
100
|
+
constructor(message, options = {}) {
|
|
101
|
+
super(message, { ...options, code: "SCP_NEXT_CONNECTION_ERROR" });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
class TransferError extends ScpNextError {
|
|
105
|
+
constructor(message, options = {}) {
|
|
106
|
+
super(message, { ...options, code: "SCP_NEXT_TRANSFER_ERROR" });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
class RemoteCommandError extends ScpNextError {
|
|
110
|
+
exitCode;
|
|
111
|
+
signal;
|
|
112
|
+
stdout;
|
|
113
|
+
stderr;
|
|
114
|
+
constructor(message, options) {
|
|
115
|
+
super(message, {
|
|
116
|
+
code: "SCP_NEXT_REMOTE_COMMAND_ERROR",
|
|
117
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
118
|
+
...(options.context !== undefined ? { context: options.context } : {})
|
|
119
|
+
});
|
|
120
|
+
this.exitCode = options.exitCode;
|
|
121
|
+
this.signal = options.signal;
|
|
122
|
+
this.stdout = options.stdout ?? "";
|
|
123
|
+
this.stderr = options.stderr ?? "";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
class FileSystemError extends ScpNextError {
|
|
127
|
+
constructor(message, options = {}) {
|
|
128
|
+
super(message, { ...options, code: "SCP_NEXT_FILESYSTEM_ERROR" });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
class HostVerificationError extends ScpNextError {
|
|
132
|
+
constructor(message, options = {}) {
|
|
133
|
+
super(message, { ...options, code: "SCP_NEXT_HOST_VERIFICATION_ERROR" });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
188
136
|
function toScpNextError(error) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
137
|
+
if (error instanceof ScpNextError) {
|
|
138
|
+
return error;
|
|
139
|
+
}
|
|
140
|
+
const message = error instanceof Error ? error.message : "Unexpected scp-next error.";
|
|
141
|
+
const lowerMessage = message.toLowerCase();
|
|
142
|
+
if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
|
|
143
|
+
return new AuthenticationError("SSH authentication failed.", { cause: error });
|
|
144
|
+
}
|
|
145
|
+
if (lowerMessage.includes("host verification") ||
|
|
146
|
+
lowerMessage.includes("host key") ||
|
|
147
|
+
lowerMessage.includes("fingerprint") ||
|
|
148
|
+
(lowerMessage.includes("host") && lowerMessage.includes("denied"))) {
|
|
149
|
+
return new HostVerificationError("SSH host verification failed.", { cause: error });
|
|
150
|
+
}
|
|
151
|
+
if (lowerMessage.includes("timed out") ||
|
|
152
|
+
lowerMessage.includes("connection") ||
|
|
153
|
+
lowerMessage.includes("econn")) {
|
|
154
|
+
return new ConnectionError("SSH connection failed.", { cause: error });
|
|
155
|
+
}
|
|
156
|
+
return new TransferError(String(redactSensitiveValues(message)), { cause: error });
|
|
204
157
|
}
|
|
205
158
|
|
|
206
|
-
// src/client/ssh-options.ts
|
|
207
|
-
var import_promises3 = require("fs/promises");
|
|
208
|
-
|
|
209
|
-
// src/security/host-verification.ts
|
|
210
|
-
var import_node_crypto = require("crypto");
|
|
211
|
-
var import_promises2 = require("fs/promises");
|
|
212
|
-
|
|
213
|
-
// src/paths/local-path.ts
|
|
214
|
-
var import_node_fs = require("fs");
|
|
215
|
-
var import_promises = require("fs/promises");
|
|
216
|
-
var import_node_os = __toESM(require("os"), 1);
|
|
217
|
-
var import_node_path = __toESM(require("path"), 1);
|
|
218
159
|
function expandHome(inputPath) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
160
|
+
if (inputPath === "~") {
|
|
161
|
+
return os.homedir();
|
|
162
|
+
}
|
|
163
|
+
if (inputPath.startsWith(`~${path.sep}`) || inputPath.startsWith("~/")) {
|
|
164
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
165
|
+
}
|
|
166
|
+
return inputPath;
|
|
226
167
|
}
|
|
227
168
|
function resolveLocalPath(inputPath, baseDir = process.cwd()) {
|
|
228
|
-
|
|
229
|
-
|
|
169
|
+
const expanded = expandHome(inputPath);
|
|
170
|
+
return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(baseDir, expanded);
|
|
230
171
|
}
|
|
231
172
|
async function assertLocalPathExists(inputPath) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
173
|
+
try {
|
|
174
|
+
await promises.access(resolveLocalPath(inputPath), node_fs.constants.F_OK);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw new FileSystemError(`Local path does not exist: ${inputPath}`, {
|
|
178
|
+
cause: error,
|
|
179
|
+
context: { path: inputPath }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
240
182
|
}
|
|
241
183
|
async function assertDownloadDestination(inputPath, createDirectories = false) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
184
|
+
const resolved = resolveLocalPath(inputPath);
|
|
185
|
+
const parent = path.dirname(resolved);
|
|
186
|
+
if (createDirectories) {
|
|
187
|
+
await promises.mkdir(parent, { recursive: true });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await promises.access(parent, node_fs.constants.W_OK);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new FileSystemError(`Local destination parent does not exist or is not writable: ${parent}`, {
|
|
195
|
+
cause: error,
|
|
196
|
+
context: { path: inputPath }
|
|
197
|
+
});
|
|
198
|
+
}
|
|
256
199
|
}
|
|
257
200
|
async function getLocalPathKind(inputPath) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
201
|
+
const stats = await promises.stat(resolveLocalPath(inputPath));
|
|
202
|
+
if (stats.isDirectory()) {
|
|
203
|
+
return "directory";
|
|
204
|
+
}
|
|
205
|
+
if (stats.isFile()) {
|
|
206
|
+
return "file";
|
|
207
|
+
}
|
|
208
|
+
throw new ValidationError(`Local path must be a file or directory: ${inputPath}`, {
|
|
209
|
+
context: { path: inputPath }
|
|
210
|
+
});
|
|
268
211
|
}
|
|
269
212
|
|
|
270
|
-
// src/security/host-verification.ts
|
|
271
213
|
function normalizeFingerprint(fingerprint) {
|
|
272
|
-
|
|
214
|
+
return fingerprint
|
|
215
|
+
.trim()
|
|
216
|
+
.replace(/^SHA256:/i, "")
|
|
217
|
+
.replace(/^sha256:/i, "")
|
|
218
|
+
.replace(/=+$/g, "");
|
|
273
219
|
}
|
|
274
220
|
function isHexFingerprint(fingerprint) {
|
|
275
|
-
|
|
221
|
+
return /^[0-9a-f]+$/i.test(fingerprint);
|
|
276
222
|
}
|
|
277
223
|
function fingerprintCandidates(fingerprint) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
} else {
|
|
283
|
-
try {
|
|
284
|
-
candidates.add(Buffer.from(normalized, "base64").toString("hex"));
|
|
285
|
-
} catch {
|
|
224
|
+
const normalized = normalizeFingerprint(fingerprint);
|
|
225
|
+
const candidates = new Set([normalized]);
|
|
226
|
+
if (isHexFingerprint(normalized)) {
|
|
227
|
+
candidates.add(normalized.toLowerCase());
|
|
286
228
|
}
|
|
287
|
-
|
|
288
|
-
|
|
229
|
+
else {
|
|
230
|
+
try {
|
|
231
|
+
candidates.add(Buffer.from(normalized, "base64").toString("hex"));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Keep the original normalized value when it is not valid base64.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return [...candidates];
|
|
289
238
|
}
|
|
290
239
|
function isFingerprintMatch(actual, expected) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
240
|
+
const normalizedActual = normalizeFingerprint(actual);
|
|
241
|
+
const normalizedExpected = normalizeFingerprint(expected);
|
|
242
|
+
if (normalizedActual === normalizedExpected) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return (isHexFingerprint(normalizedActual) &&
|
|
246
|
+
isHexFingerprint(normalizedExpected) &&
|
|
247
|
+
normalizedActual.toLowerCase() === normalizedExpected.toLowerCase());
|
|
297
248
|
}
|
|
298
249
|
function hostVerificationHint(host) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
250
|
+
return [
|
|
251
|
+
"",
|
|
252
|
+
"Typical fix:",
|
|
253
|
+
` ssh ${host}`,
|
|
254
|
+
"",
|
|
255
|
+
"If you trust the host key, accept it and retry the scp-next command.",
|
|
256
|
+
"Alternatively configure hostFingerprint or knownHostsFile explicitly."
|
|
257
|
+
].join("\n");
|
|
307
258
|
}
|
|
308
259
|
async function resolveAllowedFingerprints(options) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
`SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
|
|
349
|
-
options.host
|
|
350
|
-
)}`
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
return fingerprints;
|
|
260
|
+
const fingerprints = new Set();
|
|
261
|
+
const knownHostsFile = options.knownHostsFile ?? "~/.ssh/known_hosts";
|
|
262
|
+
if (!options.host) {
|
|
263
|
+
throw new HostVerificationError("A host is required for SSH host verification.");
|
|
264
|
+
}
|
|
265
|
+
if (options.hostFingerprint) {
|
|
266
|
+
for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
|
|
267
|
+
fingerprints.add(fingerprint);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
await promises.access(expandHome(knownHostsFile));
|
|
272
|
+
const content = await promises.readFile(expandHome(knownHostsFile), "utf8");
|
|
273
|
+
for (const line of content.split(/\r?\n/)) {
|
|
274
|
+
const trimmed = line.trim();
|
|
275
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("@")) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const [hosts, algorithm, key] = trimmed.split(/\s+/);
|
|
279
|
+
const hostPatterns = hosts?.split(",") ?? [];
|
|
280
|
+
const portHost = options.port ? `[${options.host}]:${options.port}` : undefined;
|
|
281
|
+
const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
|
|
282
|
+
if (!hosts || !algorithm || !key || !matchesHost) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const keyBuffer = Buffer.from(key, "base64");
|
|
286
|
+
fingerprints.add(node_crypto.createHash("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
|
|
287
|
+
fingerprints.add(node_crypto.createHash("sha256").update(keyBuffer).digest("hex"));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (options.knownHostsFile || !options.hostFingerprint) {
|
|
292
|
+
throw new HostVerificationError("SSH host verification requires hostFingerprint or an accessible knownHostsFile.", { cause: error, context: { knownHostsFile } });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (fingerprints.size === 0) {
|
|
296
|
+
throw new HostVerificationError(`SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(options.host)}`);
|
|
297
|
+
}
|
|
298
|
+
return fingerprints;
|
|
354
299
|
}
|
|
355
300
|
function createHostVerifier(allowedFingerprints) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
301
|
+
return (hashedKey) => {
|
|
302
|
+
for (const expected of allowedFingerprints) {
|
|
303
|
+
if (isFingerprintMatch(hashedKey, expected)) {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
};
|
|
364
309
|
}
|
|
365
310
|
|
|
366
|
-
// src/client/ssh-options.ts
|
|
367
311
|
async function createSshConnectOptions(options) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
312
|
+
const connectOptions = {};
|
|
313
|
+
if (options.host !== undefined)
|
|
314
|
+
connectOptions.host = options.host;
|
|
315
|
+
if (options.port !== undefined)
|
|
316
|
+
connectOptions.port = options.port;
|
|
317
|
+
if (options.username !== undefined)
|
|
318
|
+
connectOptions.username = options.username;
|
|
319
|
+
if (options.password !== undefined)
|
|
320
|
+
connectOptions.password = options.password;
|
|
321
|
+
if (options.privateKey !== undefined) {
|
|
322
|
+
connectOptions.privateKey = options.privateKey;
|
|
323
|
+
}
|
|
324
|
+
else if (options.privateKeyFile) {
|
|
325
|
+
connectOptions.privateKey = await promises.readFile(expandHome(options.privateKeyFile), "utf8");
|
|
326
|
+
}
|
|
327
|
+
if (options.passphrase !== undefined)
|
|
328
|
+
connectOptions.passphrase = options.passphrase;
|
|
329
|
+
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
330
|
+
if (agent !== undefined)
|
|
331
|
+
connectOptions.agent = agent;
|
|
332
|
+
if (options.timeout !== undefined)
|
|
333
|
+
connectOptions.readyTimeout = options.timeout;
|
|
334
|
+
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
335
|
+
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
336
|
+
if (hostVerifier) {
|
|
337
|
+
connectOptions.hostHash = "sha256";
|
|
338
|
+
connectOptions.hostVerifier = hostVerifier;
|
|
339
|
+
}
|
|
340
|
+
return connectOptions;
|
|
392
341
|
}
|
|
393
342
|
|
|
394
|
-
// src/client/transport.ts
|
|
395
343
|
function toRemoteFileInfo(info) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
344
|
+
return {
|
|
345
|
+
type: info.type,
|
|
346
|
+
name: info.name,
|
|
347
|
+
size: info.size
|
|
348
|
+
};
|
|
401
349
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
);
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
|
|
465
|
-
} catch (error) {
|
|
466
|
-
throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
|
|
467
|
-
cause: error,
|
|
468
|
-
context: { remotePath, localPath }
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
transferOptions(onStep) {
|
|
473
|
-
if (!onStep) {
|
|
474
|
-
return void 0;
|
|
350
|
+
class Ssh2SftpTransport {
|
|
351
|
+
client;
|
|
352
|
+
constructor(name = "scp-next") {
|
|
353
|
+
this.client = new SftpClient(name, {
|
|
354
|
+
error: () => undefined,
|
|
355
|
+
end: () => undefined,
|
|
356
|
+
close: () => undefined
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async connect(options) {
|
|
360
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
361
|
+
try {
|
|
362
|
+
await this.client.connect(connectOptions);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
const converted = toScpNextError(error);
|
|
366
|
+
if (converted instanceof HostVerificationError) {
|
|
367
|
+
throw new HostVerificationError(`SSH host verification failed during connection: ${formatErrorMessage(error)}.${hostVerificationTroubleshooting(options.host)}`, { cause: error, context: connectionContext(options) });
|
|
368
|
+
}
|
|
369
|
+
if (isLikelyHostVerificationFailure(error)) {
|
|
370
|
+
throw new HostVerificationError(`SSH host verification failed during connection.${hostVerificationTroubleshooting(options.host)}`, { cause: error, context: connectionContext(options) });
|
|
371
|
+
}
|
|
372
|
+
throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
|
|
373
|
+
cause: error,
|
|
374
|
+
context: connectionContext(options)
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async close() {
|
|
379
|
+
await this.client.end();
|
|
380
|
+
}
|
|
381
|
+
async exists(remotePath) {
|
|
382
|
+
return this.client.exists(remotePath);
|
|
383
|
+
}
|
|
384
|
+
async mkdir(remotePath, recursive = true) {
|
|
385
|
+
await this.client.mkdir(remotePath, recursive);
|
|
386
|
+
}
|
|
387
|
+
async list(remotePath) {
|
|
388
|
+
const files = await this.client.list(remotePath);
|
|
389
|
+
return files.map(toRemoteFileInfo);
|
|
390
|
+
}
|
|
391
|
+
async uploadFile(localPath, remotePath, onStep) {
|
|
392
|
+
try {
|
|
393
|
+
await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
|
|
397
|
+
cause: error,
|
|
398
|
+
context: { localPath, remotePath }
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async downloadFile(remotePath, localPath, onStep) {
|
|
403
|
+
try {
|
|
404
|
+
await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
|
|
408
|
+
cause: error,
|
|
409
|
+
context: { remotePath, localPath }
|
|
410
|
+
});
|
|
411
|
+
}
|
|
475
412
|
}
|
|
413
|
+
transferOptions(onStep) {
|
|
414
|
+
if (!onStep) {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
step(transferredBytes, chunkBytes, totalBytes) {
|
|
419
|
+
onStep({ transferredBytes, chunkBytes, totalBytes });
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function connectionContext(options) {
|
|
476
425
|
return {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
426
|
+
host: options.host,
|
|
427
|
+
port: options.port,
|
|
428
|
+
username: options.username
|
|
480
429
|
};
|
|
481
|
-
}
|
|
482
|
-
};
|
|
483
|
-
function connectionContext(options) {
|
|
484
|
-
return {
|
|
485
|
-
host: options.host,
|
|
486
|
-
port: options.port,
|
|
487
|
-
username: options.username
|
|
488
|
-
};
|
|
489
430
|
}
|
|
490
431
|
function isLikelyHostVerificationFailure(error) {
|
|
491
|
-
|
|
492
|
-
|
|
432
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
433
|
+
return (message.includes("host") &&
|
|
434
|
+
(message.includes("verif") ||
|
|
435
|
+
message.includes("fingerprint") ||
|
|
436
|
+
message.includes("host key") ||
|
|
437
|
+
message.includes("key mismatch") ||
|
|
438
|
+
message.includes("denied")));
|
|
493
439
|
}
|
|
494
440
|
function hostVerificationTroubleshooting(host) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
441
|
+
const displayHost = host ?? "<host>";
|
|
442
|
+
return [
|
|
443
|
+
"",
|
|
444
|
+
"",
|
|
445
|
+
"Typical fix:",
|
|
446
|
+
` ssh ${displayHost}`,
|
|
447
|
+
"",
|
|
448
|
+
"If plain ssh works but scp-next fails, check that the host entry is available in",
|
|
449
|
+
"`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
|
|
450
|
+
"OpenSSH usually stores entries as `[host]:port`.",
|
|
451
|
+
"You can also configure hostFingerprint or knownHostsFile explicitly."
|
|
452
|
+
].join("\n");
|
|
507
453
|
}
|
|
508
454
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
455
|
+
class SshCommandExecutor {
|
|
456
|
+
client;
|
|
457
|
+
connected = false;
|
|
458
|
+
closed = true;
|
|
459
|
+
constructor(client = new ssh2.Client()) {
|
|
460
|
+
this.client = client;
|
|
461
|
+
this.client.on("error", () => {
|
|
462
|
+
this.connected = false;
|
|
463
|
+
});
|
|
464
|
+
this.client.on("close", () => {
|
|
465
|
+
this.connected = false;
|
|
466
|
+
this.closed = true;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async connect(options) {
|
|
470
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
471
|
+
this.closed = false;
|
|
472
|
+
await new Promise((resolve, reject) => {
|
|
473
|
+
const onReady = () => {
|
|
474
|
+
cleanup();
|
|
475
|
+
this.connected = true;
|
|
476
|
+
resolve();
|
|
477
|
+
};
|
|
478
|
+
const onError = (error) => {
|
|
479
|
+
cleanup();
|
|
480
|
+
this.connected = false;
|
|
481
|
+
this.closed = true;
|
|
482
|
+
try {
|
|
483
|
+
this.client.end();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// The failed connection may not have a writable socket.
|
|
487
|
+
}
|
|
488
|
+
const converted = toScpNextError(error);
|
|
489
|
+
if (converted instanceof HostVerificationError) {
|
|
490
|
+
reject(converted);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
reject(new ConnectionError(`Unable to connect command executor to SSH server: ${formatErrorMessage(error)}`, {
|
|
494
|
+
cause: error,
|
|
495
|
+
context: {
|
|
496
|
+
host: options.host,
|
|
497
|
+
port: options.port,
|
|
498
|
+
username: options.username
|
|
499
|
+
}
|
|
500
|
+
}));
|
|
501
|
+
};
|
|
502
|
+
const onClose = () => {
|
|
503
|
+
cleanup();
|
|
504
|
+
reject(new ConnectionError("SSH connection closed before it was ready."));
|
|
505
|
+
};
|
|
506
|
+
const cleanup = () => {
|
|
507
|
+
this.client.off("ready", onReady);
|
|
508
|
+
this.client.off("error", onError);
|
|
509
|
+
this.client.off("close", onClose);
|
|
510
|
+
};
|
|
511
|
+
this.client.once("ready", onReady);
|
|
512
|
+
this.client.once("error", onError);
|
|
513
|
+
this.client.once("close", onClose);
|
|
514
|
+
try {
|
|
515
|
+
this.client.connect(connectOptions);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
cleanup();
|
|
519
|
+
this.closed = true;
|
|
520
|
+
reject(new ConnectionError(`Unable to start SSH connection: ${formatErrorMessage(error)}`));
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
async exec(command, options = {}) {
|
|
525
|
+
if (!this.connected) {
|
|
526
|
+
throw new ConnectionError("Command executor is not connected.");
|
|
546
527
|
}
|
|
547
|
-
reject
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
try {
|
|
530
|
+
this.client.exec(command, (error, stream) => {
|
|
531
|
+
if (error) {
|
|
532
|
+
reject(new RemoteCommandError("Unable to start remote command.", {
|
|
533
|
+
exitCode: null
|
|
534
|
+
}));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
collectCommandResult(command, stream, options, resolve, reject);
|
|
538
|
+
});
|
|
557
539
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const cleanup = () => {
|
|
566
|
-
this.client.off("ready", onReady);
|
|
567
|
-
this.client.off("error", onError);
|
|
568
|
-
this.client.off("close", onClose);
|
|
569
|
-
};
|
|
570
|
-
this.client.once("ready", onReady);
|
|
571
|
-
this.client.once("error", onError);
|
|
572
|
-
this.client.once("close", onClose);
|
|
573
|
-
try {
|
|
574
|
-
this.client.connect(connectOptions);
|
|
575
|
-
} catch (error) {
|
|
576
|
-
cleanup();
|
|
577
|
-
this.closed = true;
|
|
578
|
-
reject(
|
|
579
|
-
new ConnectionError(
|
|
580
|
-
`Unable to start SSH connection: ${formatErrorMessage(error)}`
|
|
581
|
-
)
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
async exec(command, options = {}) {
|
|
587
|
-
if (!this.connected) {
|
|
588
|
-
throw new ConnectionError("Command executor is not connected.");
|
|
589
|
-
}
|
|
590
|
-
return new Promise((resolve, reject) => {
|
|
591
|
-
try {
|
|
592
|
-
this.client.exec(command, (error, stream) => {
|
|
593
|
-
if (error) {
|
|
594
|
-
reject(
|
|
595
|
-
new RemoteCommandError("Unable to start remote command.", {
|
|
596
|
-
exitCode: null
|
|
597
|
-
})
|
|
598
|
-
);
|
|
540
|
+
catch (error) {
|
|
541
|
+
reject(new ConnectionError(`Unable to start remote command: ${formatErrorMessage(error)}`));
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
async close() {
|
|
546
|
+
if (this.closed) {
|
|
599
547
|
return;
|
|
600
|
-
|
|
601
|
-
|
|
548
|
+
}
|
|
549
|
+
this.connected = false;
|
|
550
|
+
await new Promise((resolve) => {
|
|
551
|
+
this.client.once("close", resolve);
|
|
552
|
+
this.client.end();
|
|
602
553
|
});
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
new ConnectionError(
|
|
606
|
-
`Unable to start remote command: ${formatErrorMessage(error)}`
|
|
607
|
-
)
|
|
608
|
-
);
|
|
609
|
-
}
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
async close() {
|
|
613
|
-
if (this.closed) {
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
this.connected = false;
|
|
617
|
-
await new Promise((resolve) => {
|
|
618
|
-
this.client.once("close", resolve);
|
|
619
|
-
this.client.end();
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
623
556
|
function collectCommandResult(command, stream, options, resolve, reject) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
557
|
+
let stdout = "";
|
|
558
|
+
let stderr = "";
|
|
559
|
+
let outputBytes = 0;
|
|
560
|
+
let settled = false;
|
|
561
|
+
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024;
|
|
562
|
+
const cleanup = () => {
|
|
563
|
+
if (timer)
|
|
564
|
+
clearTimeout(timer);
|
|
565
|
+
stream.off("data", onStdoutData);
|
|
566
|
+
stream.stderr.off("data", onStderrData);
|
|
567
|
+
stream.off("close", onClose);
|
|
568
|
+
stream.off("error", onStreamError);
|
|
569
|
+
stream.stderr.off("error", onStreamError);
|
|
570
|
+
};
|
|
571
|
+
const fail = (error) => {
|
|
572
|
+
if (settled)
|
|
573
|
+
return;
|
|
574
|
+
settled = true;
|
|
575
|
+
cleanup();
|
|
576
|
+
try {
|
|
577
|
+
stream.close();
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// The connection may already be closed.
|
|
581
|
+
}
|
|
582
|
+
reject(error);
|
|
583
|
+
};
|
|
584
|
+
const appendOutput = (target, chunk) => {
|
|
585
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
586
|
+
if (outputBytes > maxBuffer) {
|
|
587
|
+
fail(new RemoteCommandError(`Remote command output exceeded maxBuffer of ${maxBuffer} bytes.`, {
|
|
588
|
+
exitCode: null,
|
|
589
|
+
stdout,
|
|
590
|
+
stderr
|
|
591
|
+
}));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (target === "stdout")
|
|
595
|
+
stdout += chunk;
|
|
596
|
+
else
|
|
597
|
+
stderr += chunk;
|
|
598
|
+
};
|
|
599
|
+
const onStreamError = () => {
|
|
600
|
+
fail(new RemoteCommandError("Remote command stream failed.", {
|
|
654
601
|
exitCode: null,
|
|
655
602
|
stdout,
|
|
656
603
|
stderr
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
new RemoteCommandError(
|
|
695
|
-
`Remote command timed out after ${options.timeout} ms.`,
|
|
696
|
-
{
|
|
697
|
-
exitCode: null,
|
|
698
|
-
stdout,
|
|
699
|
-
stderr
|
|
700
|
-
}
|
|
701
|
-
)
|
|
702
|
-
);
|
|
703
|
-
}, options.timeout);
|
|
704
|
-
stream.setEncoding("utf8");
|
|
705
|
-
stream.stderr.setEncoding("utf8");
|
|
706
|
-
stream.on("data", onStdoutData);
|
|
707
|
-
stream.stderr.on("data", onStderrData);
|
|
708
|
-
stream.once("error", onStreamError);
|
|
709
|
-
stream.stderr.once("error", onStreamError);
|
|
710
|
-
stream.once("close", onClose);
|
|
604
|
+
}));
|
|
605
|
+
};
|
|
606
|
+
const onStdoutData = (chunk) => {
|
|
607
|
+
appendOutput("stdout", chunk);
|
|
608
|
+
};
|
|
609
|
+
const onStderrData = (chunk) => {
|
|
610
|
+
appendOutput("stderr", chunk);
|
|
611
|
+
};
|
|
612
|
+
const onClose = (exitCode, signal) => {
|
|
613
|
+
if (settled)
|
|
614
|
+
return;
|
|
615
|
+
settled = true;
|
|
616
|
+
cleanup();
|
|
617
|
+
resolve({
|
|
618
|
+
command,
|
|
619
|
+
stdout,
|
|
620
|
+
stderr,
|
|
621
|
+
exitCode: typeof exitCode === "number" ? exitCode : null,
|
|
622
|
+
...(signal ? { signal } : {})
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
const timer = options.timeout === undefined
|
|
626
|
+
? undefined
|
|
627
|
+
: setTimeout(() => {
|
|
628
|
+
fail(new RemoteCommandError(`Remote command timed out after ${options.timeout} ms.`, {
|
|
629
|
+
exitCode: null,
|
|
630
|
+
stdout,
|
|
631
|
+
stderr
|
|
632
|
+
}));
|
|
633
|
+
}, options.timeout);
|
|
634
|
+
stream.setEncoding("utf8");
|
|
635
|
+
stream.stderr.setEncoding("utf8");
|
|
636
|
+
stream.on("data", onStdoutData);
|
|
637
|
+
stream.stderr.on("data", onStderrData);
|
|
638
|
+
stream.once("error", onStreamError);
|
|
639
|
+
stream.stderr.once("error", onStreamError);
|
|
640
|
+
stream.once("close", onClose);
|
|
711
641
|
}
|
|
712
642
|
|
|
713
|
-
// src/client/walk.ts
|
|
714
|
-
var import_promises4 = require("fs/promises");
|
|
715
|
-
var import_node_path3 = __toESM(require("path"), 1);
|
|
716
|
-
|
|
717
|
-
// src/paths/remote-path.ts
|
|
718
|
-
var import_node_path2 = __toESM(require("path"), 1);
|
|
719
643
|
function assertRemotePath(inputPath, label = "remote path") {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
644
|
+
if (!inputPath || inputPath.trim().length === 0) {
|
|
645
|
+
throw new ValidationError(`Invalid ${label}: remote paths must not be empty.`, {
|
|
646
|
+
context: { path: inputPath }
|
|
647
|
+
});
|
|
648
|
+
}
|
|
725
649
|
}
|
|
726
650
|
function normalizeRemotePath(inputPath) {
|
|
727
|
-
|
|
728
|
-
|
|
651
|
+
assertRemotePath(inputPath);
|
|
652
|
+
return path.posix.normalize(inputPath.replace(/\\/g, "/"));
|
|
729
653
|
}
|
|
730
654
|
function remoteJoin(...parts) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
655
|
+
const joined = path.posix.join(...parts);
|
|
656
|
+
assertRemotePath(joined);
|
|
657
|
+
return joined;
|
|
734
658
|
}
|
|
735
659
|
function remoteDirname(inputPath) {
|
|
736
|
-
|
|
737
|
-
|
|
660
|
+
assertRemotePath(inputPath);
|
|
661
|
+
return path.posix.dirname(inputPath);
|
|
738
662
|
}
|
|
739
663
|
|
|
740
|
-
// src/client/walk.ts
|
|
741
664
|
async function walkLocalFiles(rootPath) {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
665
|
+
const resolvedRoot = resolveLocalPath(rootPath);
|
|
666
|
+
const stats = await promises.stat(resolvedRoot);
|
|
667
|
+
if (stats.isFile()) {
|
|
668
|
+
return [
|
|
669
|
+
{
|
|
670
|
+
absolutePath: resolvedRoot,
|
|
671
|
+
relativePath: path.basename(resolvedRoot),
|
|
672
|
+
size: stats.size
|
|
673
|
+
}
|
|
674
|
+
];
|
|
675
|
+
}
|
|
676
|
+
const files = [];
|
|
677
|
+
async function visit(directory) {
|
|
678
|
+
const entries = await promises.readdir(directory, { withFileTypes: true });
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
const absolutePath = path.join(directory, entry.name);
|
|
681
|
+
if (entry.isDirectory()) {
|
|
682
|
+
await visit(absolutePath);
|
|
683
|
+
}
|
|
684
|
+
else if (entry.isFile()) {
|
|
685
|
+
const entryStats = await promises.stat(absolutePath);
|
|
686
|
+
files.push({
|
|
687
|
+
absolutePath,
|
|
688
|
+
relativePath: path.relative(resolvedRoot, absolutePath),
|
|
689
|
+
size: entryStats.size
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
768
693
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
return files;
|
|
694
|
+
await visit(resolvedRoot);
|
|
695
|
+
return files;
|
|
772
696
|
}
|
|
773
697
|
async function walkRemoteFiles(transport, rootPath) {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
698
|
+
const rootEntries = await transport.list(rootPath);
|
|
699
|
+
const files = [];
|
|
700
|
+
async function visit(directory, relativeBase, entries) {
|
|
701
|
+
for (const entry of entries) {
|
|
702
|
+
const fullPath = remoteJoin(directory, entry.name);
|
|
703
|
+
const relativePath = relativeBase ? remoteJoin(relativeBase, entry.name) : entry.name;
|
|
704
|
+
if (entry.type === "d") {
|
|
705
|
+
await visit(fullPath, relativePath, await transport.list(fullPath));
|
|
706
|
+
}
|
|
707
|
+
else if (entry.type === "-") {
|
|
708
|
+
files.push({
|
|
709
|
+
remotePath: fullPath,
|
|
710
|
+
relativePath,
|
|
711
|
+
size: entry.size
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
789
715
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
return files;
|
|
716
|
+
await visit(rootPath, "", rootEntries);
|
|
717
|
+
return files;
|
|
793
718
|
}
|
|
794
719
|
|
|
795
|
-
// src/config/validation.ts
|
|
796
|
-
var import_promises5 = require("fs/promises");
|
|
797
|
-
|
|
798
|
-
// src/config/post-upload-commands.ts
|
|
799
720
|
function isPostUploadCommands(value) {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
);
|
|
721
|
+
return (Array.isArray(value) &&
|
|
722
|
+
value.every((command) => typeof command === "string" && command.trim().length > 0));
|
|
803
723
|
}
|
|
804
724
|
function getPostUploadCommands(source) {
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
return commands;
|
|
725
|
+
const commands = readPostUploadCommands(source);
|
|
726
|
+
if (commands === undefined) {
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
729
|
+
if (!isPostUploadCommands(commands)) {
|
|
730
|
+
throw new ValidationError("postUploadCommands must be an array of non-empty command strings.");
|
|
731
|
+
}
|
|
732
|
+
return commands;
|
|
815
733
|
}
|
|
816
734
|
function readPostUploadCommands(source) {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
735
|
+
if (typeof source !== "object" || source === null) {
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
return source
|
|
739
|
+
.postUploadCommands;
|
|
821
740
|
}
|
|
822
741
|
|
|
823
|
-
// src/config/validation.ts
|
|
824
742
|
function validatePort(port) {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
743
|
+
if (port === undefined) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
747
|
+
throw new ValidationError("Port must be an integer between 1 and 65535.", {
|
|
748
|
+
context: { port }
|
|
749
|
+
});
|
|
750
|
+
}
|
|
833
751
|
}
|
|
834
752
|
function validateTimeout(timeout) {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
753
|
+
if (timeout === undefined) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
757
|
+
throw new ValidationError("Timeout must be a positive number.", {
|
|
758
|
+
context: { timeout }
|
|
759
|
+
});
|
|
760
|
+
}
|
|
843
761
|
}
|
|
844
762
|
async function validateServerOptions(options) {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
763
|
+
if (!options.host) {
|
|
764
|
+
throw new ValidationError("Host is required.");
|
|
765
|
+
}
|
|
766
|
+
if (!options.username) {
|
|
767
|
+
throw new ValidationError("Username is required.");
|
|
768
|
+
}
|
|
769
|
+
validatePort(options.port);
|
|
770
|
+
validateTimeout(options.timeout);
|
|
771
|
+
if (options.privateKeyFile) {
|
|
772
|
+
try {
|
|
773
|
+
await promises.access(resolveLocalPath(options.privateKeyFile));
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
throw new ValidationError(`Private key file does not exist: ${options.privateKeyFile}`, {
|
|
777
|
+
cause: error,
|
|
778
|
+
context: { privateKeyFile: options.privateKeyFile }
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const hasAuthentication = Boolean(options.password) ||
|
|
783
|
+
Boolean(options.privateKey) ||
|
|
784
|
+
Boolean(options.privateKeyFile) ||
|
|
785
|
+
Boolean(options.agent) ||
|
|
786
|
+
Boolean(process.env.SSH_AUTH_SOCK);
|
|
787
|
+
if (!hasAuthentication) {
|
|
788
|
+
throw new ValidationError("Authentication is required. Provide a password, private key, private key file, or SSH agent.");
|
|
789
|
+
}
|
|
869
790
|
}
|
|
870
791
|
async function validateUploadOptions(options) {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
792
|
+
if (!options.localPath) {
|
|
793
|
+
throw new ValidationError("Missing localPath.");
|
|
794
|
+
}
|
|
795
|
+
if (!options.remotePath) {
|
|
796
|
+
throw new ValidationError("Missing remotePath.");
|
|
797
|
+
}
|
|
798
|
+
assertRemotePath(options.remotePath, "remotePath");
|
|
799
|
+
validateTimeout(options.timeout);
|
|
800
|
+
getPostUploadCommands(options);
|
|
801
|
+
await assertLocalPathExists(options.localPath);
|
|
802
|
+
if (!options.dryRun) {
|
|
803
|
+
await validateServerOptions(options);
|
|
804
|
+
}
|
|
884
805
|
}
|
|
885
806
|
async function validateDownloadOptions(options) {
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
807
|
+
if (!options.remotePath) {
|
|
808
|
+
throw new ValidationError("Missing remotePath.");
|
|
809
|
+
}
|
|
810
|
+
if (!options.localPath) {
|
|
811
|
+
throw new ValidationError("Missing localPath.");
|
|
812
|
+
}
|
|
813
|
+
assertRemotePath(options.remotePath, "remotePath");
|
|
814
|
+
validateTimeout(options.timeout);
|
|
815
|
+
if (getPostUploadCommands(options)?.length) {
|
|
816
|
+
throw new ValidationError("Post-upload commands are not supported for downloads.");
|
|
817
|
+
}
|
|
818
|
+
await assertDownloadDestination(options.localPath, options.createDirectories ?? true);
|
|
819
|
+
if (!options.dryRun) {
|
|
820
|
+
await validateServerOptions(options);
|
|
821
|
+
}
|
|
901
822
|
}
|
|
902
823
|
|
|
903
|
-
// src/client/client.ts
|
|
904
824
|
function percentage(transferredBytes, totalBytes) {
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
825
|
+
if (!totalBytes || totalBytes <= 0) {
|
|
826
|
+
return undefined;
|
|
827
|
+
}
|
|
828
|
+
return Math.min(100, Math.round((transferredBytes / totalBytes) * 100));
|
|
909
829
|
}
|
|
910
830
|
function mergeOptions(serverOptions, transferOptions) {
|
|
911
|
-
|
|
831
|
+
return { ...serverOptions, ...(transferOptions ?? {}) };
|
|
912
832
|
}
|
|
913
833
|
function shouldCreateDirectories(options) {
|
|
914
|
-
|
|
834
|
+
return options.createDirectories ?? true;
|
|
915
835
|
}
|
|
916
836
|
function hasTrailingPathSeparator(inputPath) {
|
|
917
|
-
|
|
837
|
+
return /[\\/]$/.test(inputPath);
|
|
918
838
|
}
|
|
919
839
|
function trimmedRemoteDirectory(remotePath) {
|
|
920
|
-
|
|
840
|
+
return remotePath.replace(/\/+$/g, "") || "/";
|
|
921
841
|
}
|
|
922
842
|
function remoteBasename(remotePath) {
|
|
923
|
-
|
|
843
|
+
return path.posix.basename(trimmedRemoteDirectory(remotePath));
|
|
924
844
|
}
|
|
925
845
|
async function isExistingLocalDirectory(localPath) {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
846
|
+
try {
|
|
847
|
+
return (await promises.stat(localPath)).isDirectory();
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
931
852
|
}
|
|
932
853
|
async function ensureLocalDirectory(localPath, createParents = false) {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
854
|
+
try {
|
|
855
|
+
const stats = await promises.stat(localPath);
|
|
856
|
+
if (stats.isDirectory()) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
throw new TransferError(`Local destination already exists and is not a directory: ${localPath}`, {
|
|
860
|
+
context: { localPath }
|
|
861
|
+
});
|
|
937
862
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
throw error;
|
|
863
|
+
catch (error) {
|
|
864
|
+
if (error instanceof TransferError) {
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
await promises.mkdir(localPath, { recursive: createParents });
|
|
944
868
|
}
|
|
945
|
-
await (0, import_promises6.mkdir)(localPath, { recursive: createParents });
|
|
946
|
-
}
|
|
947
869
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
959
|
-
async connect() {
|
|
960
|
-
await validateServerOptions(this.serverOptions);
|
|
961
|
-
await this.transport.connect(this.serverOptions);
|
|
962
|
-
this.connected = true;
|
|
963
|
-
}
|
|
964
|
-
async upload(localPath, remotePath, options = {}) {
|
|
965
|
-
const uploadOptions = {
|
|
966
|
-
...mergeOptions(this.serverOptions, options),
|
|
967
|
-
localPath,
|
|
968
|
-
remotePath
|
|
969
|
-
};
|
|
970
|
-
await validateUploadOptions(uploadOptions);
|
|
971
|
-
if (options.dryRun) {
|
|
972
|
-
return [];
|
|
973
|
-
}
|
|
974
|
-
await this.ensureConnected();
|
|
975
|
-
await this.uploadWithTransport(uploadOptions);
|
|
976
|
-
const results = [];
|
|
977
|
-
for (const command of getPostUploadCommands(options) ?? []) {
|
|
978
|
-
results.push(await this.exec(command));
|
|
979
|
-
}
|
|
980
|
-
return results;
|
|
981
|
-
}
|
|
982
|
-
async download(remotePath, localPath, options = {}) {
|
|
983
|
-
const downloadOptions = {
|
|
984
|
-
...mergeOptions(this.serverOptions, options),
|
|
985
|
-
remotePath,
|
|
986
|
-
localPath
|
|
987
|
-
};
|
|
988
|
-
await validateDownloadOptions(downloadOptions);
|
|
989
|
-
if (options.dryRun) {
|
|
990
|
-
return;
|
|
870
|
+
class ScpNextClientImpl {
|
|
871
|
+
serverOptions;
|
|
872
|
+
transport;
|
|
873
|
+
commandExecutor;
|
|
874
|
+
connected = false;
|
|
875
|
+
commandExecutorConnected = false;
|
|
876
|
+
constructor(serverOptions, dependencies = {}) {
|
|
877
|
+
this.serverOptions = serverOptions;
|
|
878
|
+
this.transport = dependencies.transport ?? new Ssh2SftpTransport();
|
|
879
|
+
this.commandExecutor = dependencies.commandExecutor ?? new SshCommandExecutor();
|
|
991
880
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
if (typeof command !== "string" || !command.trim()) {
|
|
997
|
-
throw new ValidationError("Remote command must not be empty.");
|
|
881
|
+
async connect() {
|
|
882
|
+
await validateServerOptions(this.serverOptions);
|
|
883
|
+
await this.transport.connect(this.serverOptions);
|
|
884
|
+
this.connected = true;
|
|
998
885
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
886
|
+
async upload(localPath, remotePath, options = {}) {
|
|
887
|
+
const uploadOptions = {
|
|
888
|
+
...mergeOptions(this.serverOptions, options),
|
|
889
|
+
localPath,
|
|
890
|
+
remotePath
|
|
891
|
+
};
|
|
892
|
+
await validateUploadOptions(uploadOptions);
|
|
893
|
+
if (options.dryRun) {
|
|
894
|
+
return [];
|
|
895
|
+
}
|
|
896
|
+
await this.ensureConnected();
|
|
897
|
+
await this.uploadWithTransport(uploadOptions);
|
|
898
|
+
const results = [];
|
|
899
|
+
for (const command of getPostUploadCommands(options) ?? []) {
|
|
900
|
+
results.push(await this.exec(command));
|
|
901
|
+
}
|
|
902
|
+
return results;
|
|
1001
903
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
904
|
+
async download(remotePath, localPath, options = {}) {
|
|
905
|
+
const downloadOptions = {
|
|
906
|
+
...mergeOptions(this.serverOptions, options),
|
|
907
|
+
remotePath,
|
|
908
|
+
localPath
|
|
909
|
+
};
|
|
910
|
+
await validateDownloadOptions(downloadOptions);
|
|
911
|
+
if (options.dryRun) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
await this.ensureConnected();
|
|
915
|
+
await this.downloadWithTransport(downloadOptions);
|
|
1004
916
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
917
|
+
async exec(command, options = {}) {
|
|
918
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
919
|
+
throw new ValidationError("Remote command must not be empty.");
|
|
920
|
+
}
|
|
921
|
+
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0)) {
|
|
922
|
+
throw new ValidationError("Command timeout must be a positive number.");
|
|
923
|
+
}
|
|
924
|
+
if (options.maxBuffer !== undefined &&
|
|
925
|
+
(!Number.isInteger(options.maxBuffer) || options.maxBuffer <= 0)) {
|
|
926
|
+
throw new ValidationError("Command maxBuffer must be a positive integer.");
|
|
927
|
+
}
|
|
928
|
+
if (options.failOnStderr !== undefined &&
|
|
929
|
+
typeof options.failOnStderr !== "boolean") {
|
|
930
|
+
throw new ValidationError("Command failOnStderr must be a boolean.");
|
|
931
|
+
}
|
|
932
|
+
await this.ensureCommandExecutorConnected();
|
|
933
|
+
let result;
|
|
934
|
+
try {
|
|
935
|
+
result = await this.commandExecutor.exec(command, options);
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
if (error instanceof RemoteCommandError) {
|
|
939
|
+
throw new RemoteCommandError(error.message, {
|
|
940
|
+
exitCode: error.exitCode,
|
|
941
|
+
signal: error.signal,
|
|
942
|
+
stdout: redactKnownSensitiveValues(error.stdout, this.serverOptions),
|
|
943
|
+
stderr: redactKnownSensitiveValues(error.stderr, this.serverOptions)
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
if (result.exitCode !== 0 || (options.failOnStderr && result.stderr.length > 0)) {
|
|
949
|
+
const reason = result.exitCode === null
|
|
950
|
+
? "Remote command ended without an exit code."
|
|
951
|
+
: result.exitCode !== 0
|
|
952
|
+
? `Remote command failed with exit code ${String(result.exitCode)}.`
|
|
953
|
+
: "Remote command wrote to stderr.";
|
|
954
|
+
throw new RemoteCommandError(reason, {
|
|
955
|
+
exitCode: result.exitCode,
|
|
956
|
+
signal: result.signal,
|
|
957
|
+
stdout: redactKnownSensitiveValues(result.stdout, this.serverOptions),
|
|
958
|
+
stderr: redactKnownSensitiveValues(result.stderr, this.serverOptions)
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
return result;
|
|
1007
962
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
throw error;
|
|
1022
|
-
}
|
|
1023
|
-
if (result.exitCode !== 0 || options.failOnStderr && result.stderr.length > 0) {
|
|
1024
|
-
const reason = result.exitCode === null ? "Remote command ended without an exit code." : result.exitCode !== 0 ? `Remote command failed with exit code ${String(result.exitCode)}.` : "Remote command wrote to stderr.";
|
|
1025
|
-
throw new RemoteCommandError(reason, {
|
|
1026
|
-
exitCode: result.exitCode,
|
|
1027
|
-
signal: result.signal,
|
|
1028
|
-
stdout: redactKnownSensitiveValues(result.stdout, this.serverOptions),
|
|
1029
|
-
stderr: redactKnownSensitiveValues(result.stderr, this.serverOptions)
|
|
1030
|
-
});
|
|
963
|
+
async close() {
|
|
964
|
+
const closures = [];
|
|
965
|
+
if (this.connected)
|
|
966
|
+
closures.push(this.transport.close());
|
|
967
|
+
if (this.commandExecutorConnected)
|
|
968
|
+
closures.push(this.commandExecutor.close());
|
|
969
|
+
const results = await Promise.allSettled(closures);
|
|
970
|
+
this.connected = false;
|
|
971
|
+
this.commandExecutorConnected = false;
|
|
972
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
973
|
+
if (failure) {
|
|
974
|
+
throw failure.reason;
|
|
975
|
+
}
|
|
1031
976
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
if (remoteKind === "d") {
|
|
1186
|
-
if (!options.recursive) {
|
|
1187
|
-
throw new ValidationError("Downloading a directory requires recursive mode.");
|
|
1188
|
-
}
|
|
1189
|
-
await this.downloadDirectory(options);
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
await this.downloadSingleFile(options, remotePath);
|
|
1193
|
-
}
|
|
1194
|
-
async downloadSingleFile(options, remotePath) {
|
|
1195
|
-
const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
|
|
1196
|
-
await this.assertLocalOverwrite(localPath, options.overwrite);
|
|
1197
|
-
if (shouldCreateDirectories(options)) {
|
|
1198
|
-
await (0, import_promises6.mkdir)(import_node_path4.default.dirname(localPath), { recursive: true });
|
|
1199
|
-
}
|
|
1200
|
-
await this.transport.downloadFile(remotePath, localPath, (step) => {
|
|
1201
|
-
options.onProgress?.({
|
|
1202
|
-
operation: "download",
|
|
1203
|
-
source: options.remotePath,
|
|
1204
|
-
destination: options.localPath,
|
|
1205
|
-
transferredBytes: step.transferredBytes,
|
|
1206
|
-
totalBytes: step.totalBytes,
|
|
1207
|
-
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
1208
|
-
currentFile: options.remotePath
|
|
1209
|
-
});
|
|
1210
|
-
});
|
|
1211
|
-
}
|
|
1212
|
-
async downloadDirectory(options) {
|
|
1213
|
-
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1214
|
-
const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
|
|
1215
|
-
const files = await walkRemoteFiles(this.transport, remotePath);
|
|
1216
|
-
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
1217
|
-
let completedBytes = 0;
|
|
1218
|
-
let completedFiles = 0;
|
|
1219
|
-
await ensureLocalDirectory(localRoot, shouldCreateDirectories(options));
|
|
1220
|
-
for (const file of files) {
|
|
1221
|
-
const localFile = import_node_path4.default.join(localRoot, ...file.relativePath.split("/"));
|
|
1222
|
-
await this.assertLocalOverwrite(localFile, options.overwrite);
|
|
1223
|
-
await (0, import_promises6.mkdir)(import_node_path4.default.dirname(localFile), { recursive: true });
|
|
1224
|
-
await this.transport.downloadFile(file.remotePath, localFile, (step) => {
|
|
1225
|
-
const transferredBytes = completedBytes + step.transferredBytes;
|
|
1226
|
-
options.onProgress?.({
|
|
1227
|
-
operation: "download",
|
|
1228
|
-
source: options.remotePath,
|
|
1229
|
-
destination: options.localPath,
|
|
1230
|
-
transferredBytes,
|
|
1231
|
-
totalBytes,
|
|
1232
|
-
percentage: percentage(transferredBytes, totalBytes),
|
|
1233
|
-
currentFile: file.relativePath,
|
|
1234
|
-
completedFiles,
|
|
1235
|
-
totalFiles: files.length
|
|
1236
|
-
});
|
|
1237
|
-
});
|
|
1238
|
-
completedBytes += file.size;
|
|
1239
|
-
completedFiles += 1;
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
async resolveLocalDestinationForRemoteFile(options, remotePath) {
|
|
1243
|
-
const localDestination = resolveLocalPath(options.localPath);
|
|
1244
|
-
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
|
|
1245
|
-
if (!isDirectoryLikeDestination) {
|
|
1246
|
-
return localDestination;
|
|
1247
|
-
}
|
|
1248
|
-
if (!await isExistingLocalDirectory(localDestination)) {
|
|
1249
|
-
if (!shouldCreateDirectories(options)) {
|
|
1250
|
-
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1251
|
-
context: { localPath: options.localPath }
|
|
1252
|
-
});
|
|
1253
|
-
}
|
|
1254
|
-
await (0, import_promises6.mkdir)(localDestination, { recursive: true });
|
|
1255
|
-
}
|
|
1256
|
-
return import_node_path4.default.join(localDestination, remoteBasename(remotePath));
|
|
1257
|
-
}
|
|
1258
|
-
async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
|
|
1259
|
-
const localDestination = resolveLocalPath(options.localPath);
|
|
1260
|
-
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
|
|
1261
|
-
if (!isDirectoryLikeDestination) {
|
|
1262
|
-
return localDestination;
|
|
1263
|
-
}
|
|
1264
|
-
if (!await isExistingLocalDirectory(localDestination)) {
|
|
1265
|
-
if (!shouldCreateDirectories(options)) {
|
|
1266
|
-
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1267
|
-
context: { localPath: options.localPath }
|
|
977
|
+
async ensureConnected() {
|
|
978
|
+
if (!this.connected) {
|
|
979
|
+
await this.connect();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async ensureCommandExecutorConnected() {
|
|
983
|
+
if (!this.commandExecutorConnected) {
|
|
984
|
+
await validateServerOptions(this.serverOptions);
|
|
985
|
+
await this.commandExecutor.connect(this.serverOptions);
|
|
986
|
+
this.commandExecutorConnected = true;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
async uploadWithTransport(options) {
|
|
990
|
+
const localKind = await getLocalPathKind(options.localPath);
|
|
991
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
992
|
+
if (localKind === "directory" && !options.recursive) {
|
|
993
|
+
throw new ValidationError("Uploading a directory requires recursive mode.");
|
|
994
|
+
}
|
|
995
|
+
if (localKind === "file") {
|
|
996
|
+
const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
|
|
997
|
+
const totalBytes = undefined;
|
|
998
|
+
await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
|
|
999
|
+
options.onProgress?.({
|
|
1000
|
+
operation: "upload",
|
|
1001
|
+
source: options.localPath,
|
|
1002
|
+
destination: options.remotePath,
|
|
1003
|
+
transferredBytes: step.transferredBytes,
|
|
1004
|
+
totalBytes: step.totalBytes || totalBytes,
|
|
1005
|
+
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
1006
|
+
currentFile: options.localPath
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const files = await walkLocalFiles(options.localPath);
|
|
1012
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
1013
|
+
let completedBytes = 0;
|
|
1014
|
+
let completedFiles = 0;
|
|
1015
|
+
const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(options, remotePath);
|
|
1016
|
+
for (const file of files) {
|
|
1017
|
+
const destinationFile = remoteJoin(destinationRoot, file.relativePath.split(path.sep).join("/"));
|
|
1018
|
+
await this.ensureRemoteParent(destinationFile, true);
|
|
1019
|
+
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
1020
|
+
await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
|
|
1021
|
+
const transferredBytes = completedBytes + step.transferredBytes;
|
|
1022
|
+
options.onProgress?.({
|
|
1023
|
+
operation: "upload",
|
|
1024
|
+
source: options.localPath,
|
|
1025
|
+
destination: options.remotePath,
|
|
1026
|
+
transferredBytes,
|
|
1027
|
+
totalBytes,
|
|
1028
|
+
percentage: percentage(transferredBytes, totalBytes),
|
|
1029
|
+
currentFile: file.relativePath,
|
|
1030
|
+
completedFiles,
|
|
1031
|
+
totalFiles: files.length
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
completedBytes += file.size;
|
|
1035
|
+
completedFiles += 1;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
async resolveRemoteDestinationForLocalFile(options, remotePath) {
|
|
1039
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
1040
|
+
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
1041
|
+
if (!isDirectoryLikeDestination) {
|
|
1042
|
+
await this.ensureRemoteParent(remotePath, shouldCreateDirectories(options));
|
|
1043
|
+
await this.assertRemoteOverwrite(remotePath, options.overwrite);
|
|
1044
|
+
return remotePath;
|
|
1045
|
+
}
|
|
1046
|
+
if (remoteKind && remoteKind !== "d") {
|
|
1047
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1048
|
+
context: { remotePath }
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
|
|
1052
|
+
if (!remoteKind) {
|
|
1053
|
+
if (!shouldCreateDirectories(options)) {
|
|
1054
|
+
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
1055
|
+
context: { remotePath }
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
await this.transport.mkdir(remoteDirectory, true);
|
|
1059
|
+
}
|
|
1060
|
+
const destinationFile = remoteJoin(remoteDirectory, path.basename(resolveLocalPath(options.localPath)));
|
|
1061
|
+
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
1062
|
+
return destinationFile;
|
|
1063
|
+
}
|
|
1064
|
+
async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
|
|
1065
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
1066
|
+
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
1067
|
+
const localDirectoryName = path.basename(resolveLocalPath(options.localPath));
|
|
1068
|
+
if (remoteKind && remoteKind !== "d") {
|
|
1069
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1070
|
+
context: { remotePath }
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
if (isDirectoryLikeDestination) {
|
|
1074
|
+
const remoteDirectory = trimmedRemoteDirectory(remotePath);
|
|
1075
|
+
if (!remoteKind) {
|
|
1076
|
+
if (!shouldCreateDirectories(options)) {
|
|
1077
|
+
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
1078
|
+
context: { remotePath }
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
await this.transport.mkdir(remoteDirectory, true);
|
|
1082
|
+
}
|
|
1083
|
+
const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
|
|
1084
|
+
await this.ensureRemoteDirectory(destinationRoot);
|
|
1085
|
+
return destinationRoot;
|
|
1086
|
+
}
|
|
1087
|
+
if (!remoteKind) {
|
|
1088
|
+
if (!shouldCreateDirectories(options)) {
|
|
1089
|
+
throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
|
|
1090
|
+
context: { remotePath }
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
await this.transport.mkdir(remotePath, true);
|
|
1094
|
+
}
|
|
1095
|
+
return remotePath;
|
|
1096
|
+
}
|
|
1097
|
+
async downloadWithTransport(options) {
|
|
1098
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1099
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
1100
|
+
if (!remoteKind) {
|
|
1101
|
+
throw new TransferError(`Remote path does not exist: ${options.remotePath}`, {
|
|
1102
|
+
context: { remotePath: options.remotePath }
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
if (remoteKind === "d") {
|
|
1106
|
+
if (!options.recursive) {
|
|
1107
|
+
throw new ValidationError("Downloading a directory requires recursive mode.");
|
|
1108
|
+
}
|
|
1109
|
+
await this.downloadDirectory(options);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
await this.downloadSingleFile(options, remotePath);
|
|
1113
|
+
}
|
|
1114
|
+
async downloadSingleFile(options, remotePath) {
|
|
1115
|
+
const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
|
|
1116
|
+
await this.assertLocalOverwrite(localPath, options.overwrite);
|
|
1117
|
+
if (shouldCreateDirectories(options)) {
|
|
1118
|
+
await promises.mkdir(path.dirname(localPath), { recursive: true });
|
|
1119
|
+
}
|
|
1120
|
+
await this.transport.downloadFile(remotePath, localPath, (step) => {
|
|
1121
|
+
options.onProgress?.({
|
|
1122
|
+
operation: "download",
|
|
1123
|
+
source: options.remotePath,
|
|
1124
|
+
destination: options.localPath,
|
|
1125
|
+
transferredBytes: step.transferredBytes,
|
|
1126
|
+
totalBytes: step.totalBytes,
|
|
1127
|
+
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
1128
|
+
currentFile: options.remotePath
|
|
1129
|
+
});
|
|
1268
1130
|
});
|
|
1269
|
-
}
|
|
1270
|
-
await (0, import_promises6.mkdir)(localDestination, { recursive: true });
|
|
1271
1131
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1132
|
+
async downloadDirectory(options) {
|
|
1133
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1134
|
+
const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
|
|
1135
|
+
const files = await walkRemoteFiles(this.transport, remotePath);
|
|
1136
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
1137
|
+
let completedBytes = 0;
|
|
1138
|
+
let completedFiles = 0;
|
|
1139
|
+
await ensureLocalDirectory(localRoot, shouldCreateDirectories(options));
|
|
1140
|
+
for (const file of files) {
|
|
1141
|
+
const localFile = path.join(localRoot, ...file.relativePath.split("/"));
|
|
1142
|
+
await this.assertLocalOverwrite(localFile, options.overwrite);
|
|
1143
|
+
await promises.mkdir(path.dirname(localFile), { recursive: true });
|
|
1144
|
+
await this.transport.downloadFile(file.remotePath, localFile, (step) => {
|
|
1145
|
+
const transferredBytes = completedBytes + step.transferredBytes;
|
|
1146
|
+
options.onProgress?.({
|
|
1147
|
+
operation: "download",
|
|
1148
|
+
source: options.remotePath,
|
|
1149
|
+
destination: options.localPath,
|
|
1150
|
+
transferredBytes,
|
|
1151
|
+
totalBytes,
|
|
1152
|
+
percentage: percentage(transferredBytes, totalBytes),
|
|
1153
|
+
currentFile: file.relativePath,
|
|
1154
|
+
completedFiles,
|
|
1155
|
+
totalFiles: files.length
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
completedBytes += file.size;
|
|
1159
|
+
completedFiles += 1;
|
|
1160
|
+
}
|
|
1278
1161
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
context: { remotePath }
|
|
1295
|
-
});
|
|
1296
|
-
}
|
|
1297
|
-
if (!exists) {
|
|
1298
|
-
await this.transport.mkdir(remotePath, true);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
async assertRemoteOverwrite(remotePath, overwrite = false) {
|
|
1302
|
-
if (overwrite) {
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
const exists = await this.transport.exists(remotePath);
|
|
1306
|
-
if (exists) {
|
|
1307
|
-
throw new TransferError(
|
|
1308
|
-
`Remote destination already exists: ${remotePath}. Use --overwrite to overwrite it.`,
|
|
1309
|
-
{
|
|
1310
|
-
context: { remotePath }
|
|
1311
|
-
}
|
|
1312
|
-
);
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
async assertLocalOverwrite(localPath, overwrite = false) {
|
|
1316
|
-
if (overwrite) {
|
|
1317
|
-
return;
|
|
1162
|
+
async resolveLocalDestinationForRemoteFile(options, remotePath) {
|
|
1163
|
+
const localDestination = resolveLocalPath(options.localPath);
|
|
1164
|
+
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || (await isExistingLocalDirectory(localDestination));
|
|
1165
|
+
if (!isDirectoryLikeDestination) {
|
|
1166
|
+
return localDestination;
|
|
1167
|
+
}
|
|
1168
|
+
if (!(await isExistingLocalDirectory(localDestination))) {
|
|
1169
|
+
if (!shouldCreateDirectories(options)) {
|
|
1170
|
+
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1171
|
+
context: { localPath: options.localPath }
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
await promises.mkdir(localDestination, { recursive: true });
|
|
1175
|
+
}
|
|
1176
|
+
return path.join(localDestination, remoteBasename(remotePath));
|
|
1318
1177
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1178
|
+
async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
|
|
1179
|
+
const localDestination = resolveLocalPath(options.localPath);
|
|
1180
|
+
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || (await isExistingLocalDirectory(localDestination));
|
|
1181
|
+
if (!isDirectoryLikeDestination) {
|
|
1182
|
+
return localDestination;
|
|
1183
|
+
}
|
|
1184
|
+
if (!(await isExistingLocalDirectory(localDestination))) {
|
|
1185
|
+
if (!shouldCreateDirectories(options)) {
|
|
1186
|
+
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1187
|
+
context: { localPath: options.localPath }
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
await promises.mkdir(localDestination, { recursive: true });
|
|
1191
|
+
}
|
|
1192
|
+
return path.join(localDestination, remoteBasename(remotePath));
|
|
1328
1193
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1194
|
+
async ensureRemoteParent(remotePath, createDirectories = false) {
|
|
1195
|
+
const parent = remoteDirname(remotePath);
|
|
1196
|
+
if (parent === "." || parent === "/") {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (createDirectories) {
|
|
1200
|
+
await this.transport.mkdir(parent, true);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const exists = await this.transport.exists(parent);
|
|
1204
|
+
if (exists !== "d") {
|
|
1205
|
+
throw new TransferError(`Remote destination parent does not exist: ${parent}`, {
|
|
1206
|
+
context: { remotePath }
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
async ensureRemoteDirectory(remotePath) {
|
|
1211
|
+
const exists = await this.transport.exists(remotePath);
|
|
1212
|
+
if (exists && exists !== "d") {
|
|
1213
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1214
|
+
context: { remotePath }
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
if (!exists) {
|
|
1218
|
+
await this.transport.mkdir(remotePath, true);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async assertRemoteOverwrite(remotePath, overwrite = false) {
|
|
1222
|
+
if (overwrite) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const exists = await this.transport.exists(remotePath);
|
|
1226
|
+
if (exists) {
|
|
1227
|
+
throw new TransferError(`Remote destination already exists: ${remotePath}. Use --overwrite to overwrite it.`, {
|
|
1228
|
+
context: { remotePath }
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
async assertLocalOverwrite(localPath, overwrite = false) {
|
|
1233
|
+
if (overwrite) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
await promises.access(localPath);
|
|
1238
|
+
throw new TransferError(`Local destination already exists: ${localPath}`, {
|
|
1239
|
+
context: { localPath }
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
if (error instanceof TransferError) {
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1331
1249
|
function createClient(options) {
|
|
1332
|
-
|
|
1250
|
+
return new ScpNextClientImpl(options);
|
|
1333
1251
|
}
|
|
1334
1252
|
|
|
1335
|
-
// src/client/upload.ts
|
|
1336
1253
|
async function upload(options) {
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
try {
|
|
1340
|
-
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1341
|
-
} catch (error) {
|
|
1254
|
+
const client = new ScpNextClientImpl(options);
|
|
1255
|
+
let result;
|
|
1342
1256
|
try {
|
|
1343
|
-
|
|
1344
|
-
}
|
|
1257
|
+
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
try {
|
|
1261
|
+
await client.close();
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
// Preserve the upload or command failure as the primary error.
|
|
1265
|
+
}
|
|
1266
|
+
throw error;
|
|
1345
1267
|
}
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
await client.close();
|
|
1349
|
-
return result;
|
|
1268
|
+
await client.close();
|
|
1269
|
+
return result;
|
|
1350
1270
|
}
|
|
1351
1271
|
|
|
1352
|
-
// src/client/download.ts
|
|
1353
1272
|
async function download(options) {
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1273
|
+
const client = new ScpNextClientImpl(options);
|
|
1274
|
+
try {
|
|
1275
|
+
await client.download(options.remotePath, options.localPath, options);
|
|
1276
|
+
}
|
|
1277
|
+
finally {
|
|
1278
|
+
await client.close();
|
|
1279
|
+
}
|
|
1360
1280
|
}
|
|
1361
1281
|
|
|
1362
|
-
// src/client/copy.ts
|
|
1363
1282
|
async function copy(options) {
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1283
|
+
if (options.source.type === "local" && options.destination.type === "remote") {
|
|
1284
|
+
await upload({
|
|
1285
|
+
...options,
|
|
1286
|
+
localPath: options.source.path,
|
|
1287
|
+
remotePath: options.destination.path
|
|
1288
|
+
});
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
if (options.source.type === "remote" && options.destination.type === "local") {
|
|
1292
|
+
await download({
|
|
1293
|
+
...options,
|
|
1294
|
+
remotePath: options.source.path,
|
|
1295
|
+
localPath: options.destination.path
|
|
1296
|
+
});
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
throw new ValidationError("copy() supports only local-to-remote or remote-to-local transfers.", {
|
|
1300
|
+
context: {
|
|
1301
|
+
sourceType: options.source.type,
|
|
1302
|
+
destinationType: options.destination.type
|
|
1303
|
+
}
|
|
1377
1304
|
});
|
|
1378
|
-
return;
|
|
1379
|
-
}
|
|
1380
|
-
throw new ValidationError("copy() supports only local-to-remote or remote-to-local transfers.", {
|
|
1381
|
-
context: {
|
|
1382
|
-
sourceType: options.source.type,
|
|
1383
|
-
destinationType: options.destination.type
|
|
1384
|
-
}
|
|
1385
|
-
});
|
|
1386
1305
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
});
|
|
1405
|
-
//# sourceMappingURL=index.cjs.map
|
|
1306
|
+
|
|
1307
|
+
exports.AuthenticationError = AuthenticationError;
|
|
1308
|
+
exports.ConfigurationError = ConfigurationError;
|
|
1309
|
+
exports.ConnectionError = ConnectionError;
|
|
1310
|
+
exports.FileSystemError = FileSystemError;
|
|
1311
|
+
exports.HostVerificationError = HostVerificationError;
|
|
1312
|
+
exports.RemoteCommandError = RemoteCommandError;
|
|
1313
|
+
exports.ScpNextClientImpl = ScpNextClientImpl;
|
|
1314
|
+
exports.ScpNextError = ScpNextError;
|
|
1315
|
+
exports.TransferError = TransferError;
|
|
1316
|
+
exports.ValidationError = ValidationError;
|
|
1317
|
+
exports.copy = copy;
|
|
1318
|
+
exports.createClient = createClient;
|
|
1319
|
+
exports.download = download;
|
|
1320
|
+
exports.toScpNextError = toScpNextError;
|
|
1321
|
+
exports.upload = upload;
|
|
1322
|
+
//# sourceMappingURL=index.cjs.map
|