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