scp-next 1.1.2 → 1.1.4
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/cli/index.js
CHANGED
|
@@ -1,1708 +1,1665 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { constants, realpathSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path, { resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { Option, Command, CommanderError } from 'commander';
|
|
6
|
+
import { stat, access, mkdir, readFile, readdir } from 'node:fs/promises';
|
|
7
|
+
import SftpClient from 'ssh2-sftp-client';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { Client } from 'ssh2';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { assignDefined, formatByteSize } from 'mazey';
|
|
2
13
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// src/client/transport.ts
|
|
14
|
-
import SftpClient from "ssh2-sftp-client";
|
|
15
|
-
|
|
16
|
-
// src/security/redact.ts
|
|
17
|
-
var SENSITIVE_KEYS = [
|
|
18
|
-
"password",
|
|
19
|
-
"passphrase",
|
|
20
|
-
"privatekey",
|
|
21
|
-
"privatekeyfile",
|
|
22
|
-
"token",
|
|
23
|
-
"authorization",
|
|
24
|
-
"credential",
|
|
25
|
-
"secret"
|
|
14
|
+
const SENSITIVE_KEYS = [
|
|
15
|
+
"password",
|
|
16
|
+
"passphrase",
|
|
17
|
+
"privatekey",
|
|
18
|
+
"privatekeyfile",
|
|
19
|
+
"token",
|
|
20
|
+
"authorization",
|
|
21
|
+
"credential",
|
|
22
|
+
"secret"
|
|
26
23
|
];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
25
|
+
const SECRET_OPTION_PATTERN = /(--(?:password|passphrase|token|secret|authorization|credential)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi;
|
|
26
|
+
const SECRET_ASSIGNMENT_PATTERN = /(\b(?:password|passphrase|token|secret|authorization|credential)[A-Z0-9_-]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s&]+)/gi;
|
|
30
27
|
function isSensitiveKey(key) {
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
29
|
+
return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
|
|
33
30
|
}
|
|
34
31
|
function redactSensitiveValues(value) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
return value.replace(PRIVATE_KEY_PATTERN, "[REDACTED]");
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
const redacted = value.map((item) => redactSensitiveValues(item));
|
|
37
|
+
return redacted;
|
|
38
|
+
}
|
|
39
|
+
if (value && typeof value === "object") {
|
|
40
|
+
const redacted = {};
|
|
41
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
42
|
+
redacted[key] = isSensitiveKey(key) ? "[REDACTED]" : redactSensitiveValues(nestedValue);
|
|
43
|
+
}
|
|
44
|
+
return redacted;
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
50
47
|
}
|
|
51
48
|
function formatErrorMessage(error) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
if (error instanceof Error) {
|
|
50
|
+
return redactKnownSensitiveValues(error.message, {});
|
|
51
|
+
}
|
|
52
|
+
return redactKnownSensitiveValues(String(error), {});
|
|
56
53
|
}
|
|
57
54
|
function redactKnownSensitiveValues(text, source) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return redactedKnownValues.replace(SECRET_OPTION_PATTERN, "$1[REDACTED]").replace(SECRET_ASSIGNMENT_PATTERN, "$1[REDACTED]");
|
|
55
|
+
const sensitiveValues = collectSensitiveValues(source);
|
|
56
|
+
const redactedKnownValues = sensitiveValues.reduce((redacted, value) => redacted.split(value).join("[REDACTED]"), String(redactSensitiveValues(text)));
|
|
57
|
+
return redactedKnownValues
|
|
58
|
+
.replace(SECRET_OPTION_PATTERN, "$1[REDACTED]")
|
|
59
|
+
.replace(SECRET_ASSIGNMENT_PATTERN, "$1[REDACTED]");
|
|
64
60
|
}
|
|
65
61
|
function collectSensitiveValues(value, key = "") {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
([nestedKey, nestedValue]) => collectSensitiveValues(nestedValue, nestedKey)
|
|
80
|
-
);
|
|
62
|
+
if (isSensitiveKey(key)) {
|
|
63
|
+
if (typeof value === "string" && value) {
|
|
64
|
+
return [value];
|
|
65
|
+
}
|
|
66
|
+
if (Buffer.isBuffer(value) && value.length > 0) {
|
|
67
|
+
return [value.toString("utf8")];
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
if (!value || typeof value !== "object") {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return Object.entries(value).flatMap(([nestedKey, nestedValue]) => collectSensitiveValues(nestedValue, nestedKey));
|
|
81
75
|
}
|
|
82
76
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
};
|
|
77
|
+
class ScpNextError extends Error {
|
|
78
|
+
code;
|
|
79
|
+
context;
|
|
80
|
+
constructor(message, options = {}) {
|
|
81
|
+
super(message, { cause: options.cause });
|
|
82
|
+
this.name = new.target.name;
|
|
83
|
+
this.code = options.code ?? "SCP_NEXT_ERROR";
|
|
84
|
+
this.context = redactSensitiveValues(options.context ?? {});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
class ConfigurationError extends ScpNextError {
|
|
88
|
+
constructor(message, options = {}) {
|
|
89
|
+
super(message, { ...options, code: "SCP_NEXT_CONFIGURATION_ERROR" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
class ValidationError extends ScpNextError {
|
|
93
|
+
constructor(message, options = {}) {
|
|
94
|
+
super(message, { ...options, code: "SCP_NEXT_VALIDATION_ERROR" });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
class AuthenticationError extends ScpNextError {
|
|
98
|
+
constructor(message, options = {}) {
|
|
99
|
+
super(message, { ...options, code: "SCP_NEXT_AUTHENTICATION_ERROR" });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
class ConnectionError extends ScpNextError {
|
|
103
|
+
constructor(message, options = {}) {
|
|
104
|
+
super(message, { ...options, code: "SCP_NEXT_CONNECTION_ERROR" });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
class TransferError extends ScpNextError {
|
|
108
|
+
constructor(message, options = {}) {
|
|
109
|
+
super(message, { ...options, code: "SCP_NEXT_TRANSFER_ERROR" });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
class RemoteCommandError extends ScpNextError {
|
|
113
|
+
exitCode;
|
|
114
|
+
signal;
|
|
115
|
+
stdout;
|
|
116
|
+
stderr;
|
|
117
|
+
constructor(message, options) {
|
|
118
|
+
super(message, {
|
|
119
|
+
code: "SCP_NEXT_REMOTE_COMMAND_ERROR",
|
|
120
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
121
|
+
...(options.context !== undefined ? { context: options.context } : {})
|
|
122
|
+
});
|
|
123
|
+
this.exitCode = options.exitCode;
|
|
124
|
+
this.signal = options.signal;
|
|
125
|
+
this.stdout = options.stdout ?? "";
|
|
126
|
+
this.stderr = options.stderr ?? "";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
class FileSystemError extends ScpNextError {
|
|
130
|
+
constructor(message, options = {}) {
|
|
131
|
+
super(message, { ...options, code: "SCP_NEXT_FILESYSTEM_ERROR" });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
class HostVerificationError extends ScpNextError {
|
|
135
|
+
constructor(message, options = {}) {
|
|
136
|
+
super(message, { ...options, code: "SCP_NEXT_HOST_VERIFICATION_ERROR" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
146
139
|
function toScpNextError(error) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
140
|
+
if (error instanceof ScpNextError) {
|
|
141
|
+
return error;
|
|
142
|
+
}
|
|
143
|
+
const message = error instanceof Error ? error.message : "Unexpected scp-next error.";
|
|
144
|
+
const lowerMessage = message.toLowerCase();
|
|
145
|
+
if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
|
|
146
|
+
return new AuthenticationError("SSH authentication failed.", { cause: error });
|
|
147
|
+
}
|
|
148
|
+
if (lowerMessage.includes("host verification") ||
|
|
149
|
+
lowerMessage.includes("host key") ||
|
|
150
|
+
lowerMessage.includes("fingerprint") ||
|
|
151
|
+
(lowerMessage.includes("host") && lowerMessage.includes("denied"))) {
|
|
152
|
+
return new HostVerificationError("SSH host verification failed.", { cause: error });
|
|
153
|
+
}
|
|
154
|
+
if (lowerMessage.includes("timed out") ||
|
|
155
|
+
lowerMessage.includes("connection") ||
|
|
156
|
+
lowerMessage.includes("econn")) {
|
|
157
|
+
return new ConnectionError("SSH connection failed.", { cause: error });
|
|
158
|
+
}
|
|
159
|
+
return new TransferError(String(redactSensitiveValues(message)), { cause: error });
|
|
162
160
|
}
|
|
163
161
|
|
|
164
|
-
// src/client/ssh-options.ts
|
|
165
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
166
|
-
|
|
167
|
-
// src/security/host-verification.ts
|
|
168
|
-
import { createHash } from "crypto";
|
|
169
|
-
import { access as access2, readFile } from "fs/promises";
|
|
170
|
-
|
|
171
|
-
// src/paths/local-path.ts
|
|
172
|
-
import { constants } from "fs";
|
|
173
|
-
import { access, mkdir, stat } from "fs/promises";
|
|
174
|
-
import os from "os";
|
|
175
|
-
import path from "path";
|
|
176
162
|
function expandHome(inputPath) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
163
|
+
if (inputPath === "~") {
|
|
164
|
+
return os.homedir();
|
|
165
|
+
}
|
|
166
|
+
if (inputPath.startsWith(`~${path.sep}`) || inputPath.startsWith("~/")) {
|
|
167
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
168
|
+
}
|
|
169
|
+
return inputPath;
|
|
184
170
|
}
|
|
185
171
|
function resolveLocalPath(inputPath, baseDir = process.cwd()) {
|
|
186
|
-
|
|
187
|
-
|
|
172
|
+
const expanded = expandHome(inputPath);
|
|
173
|
+
return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(baseDir, expanded);
|
|
188
174
|
}
|
|
189
175
|
async function assertLocalPathExists(inputPath) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
176
|
+
try {
|
|
177
|
+
await access(resolveLocalPath(inputPath), constants.F_OK);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
throw new FileSystemError(`Local path does not exist: ${inputPath}`, {
|
|
181
|
+
cause: error,
|
|
182
|
+
context: { path: inputPath }
|
|
183
|
+
});
|
|
184
|
+
}
|
|
198
185
|
}
|
|
199
186
|
async function assertDownloadDestination(inputPath, createDirectories = false) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
187
|
+
const resolved = resolveLocalPath(inputPath);
|
|
188
|
+
const parent = path.dirname(resolved);
|
|
189
|
+
if (createDirectories) {
|
|
190
|
+
await mkdir(parent, { recursive: true });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await access(parent, constants.W_OK);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
throw new FileSystemError(`Local destination parent does not exist or is not writable: ${parent}`, {
|
|
198
|
+
cause: error,
|
|
199
|
+
context: { path: inputPath }
|
|
200
|
+
});
|
|
201
|
+
}
|
|
214
202
|
}
|
|
215
203
|
async function getLocalPathKind(inputPath) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
204
|
+
const stats = await stat(resolveLocalPath(inputPath));
|
|
205
|
+
if (stats.isDirectory()) {
|
|
206
|
+
return "directory";
|
|
207
|
+
}
|
|
208
|
+
if (stats.isFile()) {
|
|
209
|
+
return "file";
|
|
210
|
+
}
|
|
211
|
+
throw new ValidationError(`Local path must be a file or directory: ${inputPath}`, {
|
|
212
|
+
context: { path: inputPath }
|
|
213
|
+
});
|
|
226
214
|
}
|
|
227
215
|
|
|
228
|
-
// src/security/host-verification.ts
|
|
229
216
|
function normalizeFingerprint(fingerprint) {
|
|
230
|
-
|
|
217
|
+
return fingerprint
|
|
218
|
+
.trim()
|
|
219
|
+
.replace(/^SHA256:/i, "")
|
|
220
|
+
.replace(/^sha256:/i, "")
|
|
221
|
+
.replace(/=+$/g, "");
|
|
231
222
|
}
|
|
232
223
|
function isHexFingerprint(fingerprint) {
|
|
233
|
-
|
|
224
|
+
return /^[0-9a-f]+$/i.test(fingerprint);
|
|
234
225
|
}
|
|
235
226
|
function fingerprintCandidates(fingerprint) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
} else {
|
|
241
|
-
try {
|
|
242
|
-
candidates.add(Buffer.from(normalized, "base64").toString("hex"));
|
|
243
|
-
} catch {
|
|
227
|
+
const normalized = normalizeFingerprint(fingerprint);
|
|
228
|
+
const candidates = new Set([normalized]);
|
|
229
|
+
if (isHexFingerprint(normalized)) {
|
|
230
|
+
candidates.add(normalized.toLowerCase());
|
|
244
231
|
}
|
|
245
|
-
|
|
246
|
-
|
|
232
|
+
else {
|
|
233
|
+
try {
|
|
234
|
+
candidates.add(Buffer.from(normalized, "base64").toString("hex"));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Keep the original normalized value when it is not valid base64.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return [...candidates];
|
|
247
241
|
}
|
|
248
242
|
function isFingerprintMatch(actual, expected) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
243
|
+
const normalizedActual = normalizeFingerprint(actual);
|
|
244
|
+
const normalizedExpected = normalizeFingerprint(expected);
|
|
245
|
+
if (normalizedActual === normalizedExpected) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return (isHexFingerprint(normalizedActual) &&
|
|
249
|
+
isHexFingerprint(normalizedExpected) &&
|
|
250
|
+
normalizedActual.toLowerCase() === normalizedExpected.toLowerCase());
|
|
255
251
|
}
|
|
256
252
|
function hostVerificationHint(host) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
253
|
+
return [
|
|
254
|
+
"",
|
|
255
|
+
"Typical fix:",
|
|
256
|
+
` ssh ${host}`,
|
|
257
|
+
"",
|
|
258
|
+
"If you trust the host key, accept it and retry the scp-next command.",
|
|
259
|
+
"Alternatively configure hostFingerprint or knownHostsFile explicitly."
|
|
260
|
+
].join("\n");
|
|
265
261
|
}
|
|
266
262
|
async function resolveAllowedFingerprints(options) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
`SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
|
|
307
|
-
options.host
|
|
308
|
-
)}`
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
return fingerprints;
|
|
263
|
+
const fingerprints = new Set();
|
|
264
|
+
const knownHostsFile = options.knownHostsFile ?? "~/.ssh/known_hosts";
|
|
265
|
+
if (!options.host) {
|
|
266
|
+
throw new HostVerificationError("A host is required for SSH host verification.");
|
|
267
|
+
}
|
|
268
|
+
if (options.hostFingerprint) {
|
|
269
|
+
for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
|
|
270
|
+
fingerprints.add(fingerprint);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await access(expandHome(knownHostsFile));
|
|
275
|
+
const content = await readFile(expandHome(knownHostsFile), "utf8");
|
|
276
|
+
for (const line of content.split(/\r?\n/)) {
|
|
277
|
+
const trimmed = line.trim();
|
|
278
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("@")) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const [hosts, algorithm, key] = trimmed.split(/\s+/);
|
|
282
|
+
const hostPatterns = hosts?.split(",") ?? [];
|
|
283
|
+
const portHost = options.port ? `[${options.host}]:${options.port}` : undefined;
|
|
284
|
+
const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
|
|
285
|
+
if (!hosts || !algorithm || !key || !matchesHost) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const keyBuffer = Buffer.from(key, "base64");
|
|
289
|
+
fingerprints.add(createHash("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
|
|
290
|
+
fingerprints.add(createHash("sha256").update(keyBuffer).digest("hex"));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (options.knownHostsFile || !options.hostFingerprint) {
|
|
295
|
+
throw new HostVerificationError("SSH host verification requires hostFingerprint or an accessible knownHostsFile.", { cause: error, context: { knownHostsFile } });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (fingerprints.size === 0) {
|
|
299
|
+
throw new HostVerificationError(`SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(options.host)}`);
|
|
300
|
+
}
|
|
301
|
+
return fingerprints;
|
|
312
302
|
}
|
|
313
303
|
function createHostVerifier(allowedFingerprints) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
304
|
+
return (hashedKey) => {
|
|
305
|
+
for (const expected of allowedFingerprints) {
|
|
306
|
+
if (isFingerprintMatch(hashedKey, expected)) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
};
|
|
322
312
|
}
|
|
323
313
|
|
|
324
|
-
// src/client/ssh-options.ts
|
|
325
314
|
async function createSshConnectOptions(options) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
315
|
+
const connectOptions = {};
|
|
316
|
+
if (options.host !== undefined)
|
|
317
|
+
connectOptions.host = options.host;
|
|
318
|
+
if (options.port !== undefined)
|
|
319
|
+
connectOptions.port = options.port;
|
|
320
|
+
if (options.username !== undefined)
|
|
321
|
+
connectOptions.username = options.username;
|
|
322
|
+
if (options.password !== undefined)
|
|
323
|
+
connectOptions.password = options.password;
|
|
324
|
+
if (options.privateKey !== undefined) {
|
|
325
|
+
connectOptions.privateKey = options.privateKey;
|
|
326
|
+
}
|
|
327
|
+
else if (options.privateKeyFile) {
|
|
328
|
+
connectOptions.privateKey = await readFile(expandHome(options.privateKeyFile), "utf8");
|
|
329
|
+
}
|
|
330
|
+
if (options.passphrase !== undefined)
|
|
331
|
+
connectOptions.passphrase = options.passphrase;
|
|
332
|
+
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
333
|
+
if (agent !== undefined)
|
|
334
|
+
connectOptions.agent = agent;
|
|
335
|
+
if (options.timeout !== undefined)
|
|
336
|
+
connectOptions.readyTimeout = options.timeout;
|
|
337
|
+
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
338
|
+
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
339
|
+
if (hostVerifier) {
|
|
340
|
+
connectOptions.hostHash = "sha256";
|
|
341
|
+
connectOptions.hostVerifier = hostVerifier;
|
|
342
|
+
}
|
|
343
|
+
return connectOptions;
|
|
350
344
|
}
|
|
351
345
|
|
|
352
|
-
// src/client/transport.ts
|
|
353
346
|
function toRemoteFileInfo(info) {
|
|
354
|
-
return {
|
|
355
|
-
type: info.type,
|
|
356
|
-
name: info.name,
|
|
357
|
-
size: info.size
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
var Ssh2SftpTransport = class {
|
|
361
|
-
client;
|
|
362
|
-
constructor(name = "scp-next") {
|
|
363
|
-
this.client = new SftpClient(name, {
|
|
364
|
-
error: () => void 0,
|
|
365
|
-
end: () => void 0,
|
|
366
|
-
close: () => void 0
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
async connect(options) {
|
|
370
|
-
const connectOptions = await createSshConnectOptions(options);
|
|
371
|
-
try {
|
|
372
|
-
await this.client.connect(connectOptions);
|
|
373
|
-
} catch (error) {
|
|
374
|
-
const converted = toScpNextError(error);
|
|
375
|
-
if (converted instanceof HostVerificationError) {
|
|
376
|
-
throw new HostVerificationError(
|
|
377
|
-
`SSH host verification failed during connection: ${formatErrorMessage(
|
|
378
|
-
error
|
|
379
|
-
)}.${hostVerificationTroubleshooting(options.host)}`,
|
|
380
|
-
{ cause: error, context: connectionContext(options) }
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
if (isLikelyHostVerificationFailure(error)) {
|
|
384
|
-
throw new HostVerificationError(
|
|
385
|
-
`SSH host verification failed during connection.${hostVerificationTroubleshooting(
|
|
386
|
-
options.host
|
|
387
|
-
)}`,
|
|
388
|
-
{ cause: error, context: connectionContext(options) }
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
|
|
392
|
-
cause: error,
|
|
393
|
-
context: connectionContext(options)
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
async close() {
|
|
398
|
-
await this.client.end();
|
|
399
|
-
}
|
|
400
|
-
async exists(remotePath) {
|
|
401
|
-
return this.client.exists(remotePath);
|
|
402
|
-
}
|
|
403
|
-
async mkdir(remotePath, recursive = true) {
|
|
404
|
-
await this.client.mkdir(remotePath, recursive);
|
|
405
|
-
}
|
|
406
|
-
async list(remotePath) {
|
|
407
|
-
const files = await this.client.list(remotePath);
|
|
408
|
-
return files.map(toRemoteFileInfo);
|
|
409
|
-
}
|
|
410
|
-
async uploadFile(localPath, remotePath, onStep) {
|
|
411
|
-
try {
|
|
412
|
-
await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
|
|
413
|
-
} catch (error) {
|
|
414
|
-
throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
|
|
415
|
-
cause: error,
|
|
416
|
-
context: { localPath, remotePath }
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
async downloadFile(remotePath, localPath, onStep) {
|
|
421
|
-
try {
|
|
422
|
-
await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
|
|
423
|
-
} catch (error) {
|
|
424
|
-
throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
|
|
425
|
-
cause: error,
|
|
426
|
-
context: { remotePath, localPath }
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
transferOptions(onStep) {
|
|
431
|
-
if (!onStep) {
|
|
432
|
-
return void 0;
|
|
433
|
-
}
|
|
434
347
|
return {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
348
|
+
type: info.type,
|
|
349
|
+
name: info.name,
|
|
350
|
+
size: info.size
|
|
438
351
|
};
|
|
439
|
-
|
|
440
|
-
|
|
352
|
+
}
|
|
353
|
+
class Ssh2SftpTransport {
|
|
354
|
+
client;
|
|
355
|
+
constructor(name = "scp-next") {
|
|
356
|
+
this.client = new SftpClient(name, {
|
|
357
|
+
error: () => undefined,
|
|
358
|
+
end: () => undefined,
|
|
359
|
+
close: () => undefined
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async connect(options) {
|
|
363
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
364
|
+
try {
|
|
365
|
+
await this.client.connect(connectOptions);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
const converted = toScpNextError(error);
|
|
369
|
+
if (converted instanceof HostVerificationError) {
|
|
370
|
+
throw new HostVerificationError(`SSH host verification failed during connection: ${formatErrorMessage(error)}.${hostVerificationTroubleshooting(options.host)}`, { cause: error, context: connectionContext(options) });
|
|
371
|
+
}
|
|
372
|
+
if (isLikelyHostVerificationFailure(error)) {
|
|
373
|
+
throw new HostVerificationError(`SSH host verification failed during connection.${hostVerificationTroubleshooting(options.host)}`, { cause: error, context: connectionContext(options) });
|
|
374
|
+
}
|
|
375
|
+
throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
|
|
376
|
+
cause: error,
|
|
377
|
+
context: connectionContext(options)
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async close() {
|
|
382
|
+
await this.client.end();
|
|
383
|
+
}
|
|
384
|
+
async exists(remotePath) {
|
|
385
|
+
return this.client.exists(remotePath);
|
|
386
|
+
}
|
|
387
|
+
async mkdir(remotePath, recursive = true) {
|
|
388
|
+
await this.client.mkdir(remotePath, recursive);
|
|
389
|
+
}
|
|
390
|
+
async list(remotePath) {
|
|
391
|
+
const files = await this.client.list(remotePath);
|
|
392
|
+
return files.map(toRemoteFileInfo);
|
|
393
|
+
}
|
|
394
|
+
async uploadFile(localPath, remotePath, onStep) {
|
|
395
|
+
try {
|
|
396
|
+
await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
|
|
400
|
+
cause: error,
|
|
401
|
+
context: { localPath, remotePath }
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async downloadFile(remotePath, localPath, onStep) {
|
|
406
|
+
try {
|
|
407
|
+
await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
|
|
411
|
+
cause: error,
|
|
412
|
+
context: { remotePath, localPath }
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
transferOptions(onStep) {
|
|
417
|
+
if (!onStep) {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
step(transferredBytes, chunkBytes, totalBytes) {
|
|
422
|
+
onStep({ transferredBytes, chunkBytes, totalBytes });
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
441
427
|
function connectionContext(options) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
428
|
+
return {
|
|
429
|
+
host: options.host,
|
|
430
|
+
port: options.port,
|
|
431
|
+
username: options.username
|
|
432
|
+
};
|
|
447
433
|
}
|
|
448
434
|
function isLikelyHostVerificationFailure(error) {
|
|
449
|
-
|
|
450
|
-
|
|
435
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
436
|
+
return (message.includes("host") &&
|
|
437
|
+
(message.includes("verif") ||
|
|
438
|
+
message.includes("fingerprint") ||
|
|
439
|
+
message.includes("host key") ||
|
|
440
|
+
message.includes("key mismatch") ||
|
|
441
|
+
message.includes("denied")));
|
|
451
442
|
}
|
|
452
443
|
function hostVerificationTroubleshooting(host) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
444
|
+
const displayHost = host ?? "<host>";
|
|
445
|
+
return [
|
|
446
|
+
"",
|
|
447
|
+
"",
|
|
448
|
+
"Typical fix:",
|
|
449
|
+
` ssh ${displayHost}`,
|
|
450
|
+
"",
|
|
451
|
+
"If plain ssh works but scp-next fails, check that the host entry is available in",
|
|
452
|
+
"`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
|
|
453
|
+
"OpenSSH usually stores entries as `[host]:port`.",
|
|
454
|
+
"You can also configure hostFingerprint or knownHostsFile explicitly."
|
|
455
|
+
].join("\n");
|
|
465
456
|
}
|
|
466
457
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
458
|
+
class SshCommandExecutor {
|
|
459
|
+
client;
|
|
460
|
+
connected = false;
|
|
461
|
+
closed = true;
|
|
462
|
+
constructor(client = new Client()) {
|
|
463
|
+
this.client = client;
|
|
464
|
+
this.client.on("error", () => {
|
|
465
|
+
this.connected = false;
|
|
466
|
+
});
|
|
467
|
+
this.client.on("close", () => {
|
|
468
|
+
this.connected = false;
|
|
469
|
+
this.closed = true;
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async connect(options) {
|
|
473
|
+
const connectOptions = await createSshConnectOptions(options);
|
|
474
|
+
this.closed = false;
|
|
475
|
+
await new Promise((resolve, reject) => {
|
|
476
|
+
const onReady = () => {
|
|
477
|
+
cleanup();
|
|
478
|
+
this.connected = true;
|
|
479
|
+
resolve();
|
|
480
|
+
};
|
|
481
|
+
const onError = (error) => {
|
|
482
|
+
cleanup();
|
|
483
|
+
this.connected = false;
|
|
484
|
+
this.closed = true;
|
|
485
|
+
try {
|
|
486
|
+
this.client.end();
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
// The failed connection may not have a writable socket.
|
|
490
|
+
}
|
|
491
|
+
const converted = toScpNextError(error);
|
|
492
|
+
if (converted instanceof HostVerificationError) {
|
|
493
|
+
reject(converted);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
reject(new ConnectionError(`Unable to connect command executor to SSH server: ${formatErrorMessage(error)}`, {
|
|
497
|
+
cause: error,
|
|
498
|
+
context: {
|
|
499
|
+
host: options.host,
|
|
500
|
+
port: options.port,
|
|
501
|
+
username: options.username
|
|
502
|
+
}
|
|
503
|
+
}));
|
|
504
|
+
};
|
|
505
|
+
const onClose = () => {
|
|
506
|
+
cleanup();
|
|
507
|
+
reject(new ConnectionError("SSH connection closed before it was ready."));
|
|
508
|
+
};
|
|
509
|
+
const cleanup = () => {
|
|
510
|
+
this.client.off("ready", onReady);
|
|
511
|
+
this.client.off("error", onError);
|
|
512
|
+
this.client.off("close", onClose);
|
|
513
|
+
};
|
|
514
|
+
this.client.once("ready", onReady);
|
|
515
|
+
this.client.once("error", onError);
|
|
516
|
+
this.client.once("close", onClose);
|
|
517
|
+
try {
|
|
518
|
+
this.client.connect(connectOptions);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
cleanup();
|
|
522
|
+
this.closed = true;
|
|
523
|
+
reject(new ConnectionError(`Unable to start SSH connection: ${formatErrorMessage(error)}`));
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
async exec(command, options = {}) {
|
|
528
|
+
if (!this.connected) {
|
|
529
|
+
throw new ConnectionError("Command executor is not connected.");
|
|
530
|
+
}
|
|
531
|
+
return new Promise((resolve, reject) => {
|
|
532
|
+
try {
|
|
533
|
+
this.client.exec(command, (error, stream) => {
|
|
534
|
+
if (error) {
|
|
535
|
+
reject(new RemoteCommandError("Unable to start remote command.", {
|
|
536
|
+
exitCode: null
|
|
537
|
+
}));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
collectCommandResult(command, stream, options, resolve, reject);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
reject(new ConnectionError(`Unable to start remote command: ${formatErrorMessage(error)}`));
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async close() {
|
|
549
|
+
if (this.closed) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
494
552
|
this.connected = false;
|
|
495
|
-
|
|
553
|
+
await new Promise((resolve) => {
|
|
554
|
+
this.client.once("close", resolve);
|
|
555
|
+
this.client.end();
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function collectCommandResult(command, stream, options, resolve, reject) {
|
|
560
|
+
let stdout = "";
|
|
561
|
+
let stderr = "";
|
|
562
|
+
let outputBytes = 0;
|
|
563
|
+
let settled = false;
|
|
564
|
+
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024;
|
|
565
|
+
const cleanup = () => {
|
|
566
|
+
if (timer)
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
stream.off("data", onStdoutData);
|
|
569
|
+
stream.stderr.off("data", onStderrData);
|
|
570
|
+
stream.off("close", onClose);
|
|
571
|
+
stream.off("error", onStreamError);
|
|
572
|
+
stream.stderr.off("error", onStreamError);
|
|
573
|
+
};
|
|
574
|
+
const fail = (error) => {
|
|
575
|
+
if (settled)
|
|
576
|
+
return;
|
|
577
|
+
settled = true;
|
|
578
|
+
cleanup();
|
|
496
579
|
try {
|
|
497
|
-
|
|
498
|
-
} catch {
|
|
580
|
+
stream.close();
|
|
499
581
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
reject(converted);
|
|
503
|
-
return;
|
|
582
|
+
catch {
|
|
583
|
+
// The connection may already be closed.
|
|
504
584
|
}
|
|
505
|
-
reject(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
)
|
|
517
|
-
);
|
|
518
|
-
};
|
|
519
|
-
const onClose = () => {
|
|
520
|
-
cleanup();
|
|
521
|
-
reject(new ConnectionError("SSH connection closed before it was ready."));
|
|
522
|
-
};
|
|
523
|
-
const cleanup = () => {
|
|
524
|
-
this.client.off("ready", onReady);
|
|
525
|
-
this.client.off("error", onError);
|
|
526
|
-
this.client.off("close", onClose);
|
|
527
|
-
};
|
|
528
|
-
this.client.once("ready", onReady);
|
|
529
|
-
this.client.once("error", onError);
|
|
530
|
-
this.client.once("close", onClose);
|
|
531
|
-
try {
|
|
532
|
-
this.client.connect(connectOptions);
|
|
533
|
-
} catch (error) {
|
|
534
|
-
cleanup();
|
|
535
|
-
this.closed = true;
|
|
536
|
-
reject(
|
|
537
|
-
new ConnectionError(
|
|
538
|
-
`Unable to start SSH connection: ${formatErrorMessage(error)}`
|
|
539
|
-
)
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
async exec(command, options = {}) {
|
|
545
|
-
if (!this.connected) {
|
|
546
|
-
throw new ConnectionError("Command executor is not connected.");
|
|
547
|
-
}
|
|
548
|
-
return new Promise((resolve2, reject) => {
|
|
549
|
-
try {
|
|
550
|
-
this.client.exec(command, (error, stream) => {
|
|
551
|
-
if (error) {
|
|
552
|
-
reject(
|
|
553
|
-
new RemoteCommandError("Unable to start remote command.", {
|
|
554
|
-
exitCode: null
|
|
555
|
-
})
|
|
556
|
-
);
|
|
585
|
+
reject(error);
|
|
586
|
+
};
|
|
587
|
+
const appendOutput = (target, chunk) => {
|
|
588
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
589
|
+
if (outputBytes > maxBuffer) {
|
|
590
|
+
fail(new RemoteCommandError(`Remote command output exceeded maxBuffer of ${maxBuffer} bytes.`, {
|
|
591
|
+
exitCode: null,
|
|
592
|
+
stdout,
|
|
593
|
+
stderr
|
|
594
|
+
}));
|
|
557
595
|
return;
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
);
|
|
567
|
-
}
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
async close() {
|
|
571
|
-
if (this.closed) {
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
this.connected = false;
|
|
575
|
-
await new Promise((resolve2) => {
|
|
576
|
-
this.client.once("close", resolve2);
|
|
577
|
-
this.client.end();
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
};
|
|
581
|
-
function collectCommandResult(command, stream, options, resolve2, reject) {
|
|
582
|
-
let stdout = "";
|
|
583
|
-
let stderr = "";
|
|
584
|
-
let outputBytes = 0;
|
|
585
|
-
let settled = false;
|
|
586
|
-
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024;
|
|
587
|
-
const cleanup = () => {
|
|
588
|
-
if (timer) clearTimeout(timer);
|
|
589
|
-
stream.off("data", onStdoutData);
|
|
590
|
-
stream.stderr.off("data", onStderrData);
|
|
591
|
-
stream.off("close", onClose);
|
|
592
|
-
stream.off("error", onStreamError);
|
|
593
|
-
stream.stderr.off("error", onStreamError);
|
|
594
|
-
};
|
|
595
|
-
const fail = (error) => {
|
|
596
|
-
if (settled) return;
|
|
597
|
-
settled = true;
|
|
598
|
-
cleanup();
|
|
599
|
-
try {
|
|
600
|
-
stream.close();
|
|
601
|
-
} catch {
|
|
602
|
-
}
|
|
603
|
-
reject(error);
|
|
604
|
-
};
|
|
605
|
-
const appendOutput = (target, chunk) => {
|
|
606
|
-
outputBytes += Buffer.byteLength(chunk);
|
|
607
|
-
if (outputBytes > maxBuffer) {
|
|
608
|
-
fail(
|
|
609
|
-
new RemoteCommandError(
|
|
610
|
-
`Remote command output exceeded maxBuffer of ${maxBuffer} bytes.`,
|
|
611
|
-
{
|
|
596
|
+
}
|
|
597
|
+
if (target === "stdout")
|
|
598
|
+
stdout += chunk;
|
|
599
|
+
else
|
|
600
|
+
stderr += chunk;
|
|
601
|
+
};
|
|
602
|
+
const onStreamError = () => {
|
|
603
|
+
fail(new RemoteCommandError("Remote command stream failed.", {
|
|
612
604
|
exitCode: null,
|
|
613
605
|
stdout,
|
|
614
606
|
stderr
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
new RemoteCommandError(
|
|
653
|
-
`Remote command timed out after ${options.timeout} ms.`,
|
|
654
|
-
{
|
|
655
|
-
exitCode: null,
|
|
656
|
-
stdout,
|
|
657
|
-
stderr
|
|
658
|
-
}
|
|
659
|
-
)
|
|
660
|
-
);
|
|
661
|
-
}, options.timeout);
|
|
662
|
-
stream.setEncoding("utf8");
|
|
663
|
-
stream.stderr.setEncoding("utf8");
|
|
664
|
-
stream.on("data", onStdoutData);
|
|
665
|
-
stream.stderr.on("data", onStderrData);
|
|
666
|
-
stream.once("error", onStreamError);
|
|
667
|
-
stream.stderr.once("error", onStreamError);
|
|
668
|
-
stream.once("close", onClose);
|
|
607
|
+
}));
|
|
608
|
+
};
|
|
609
|
+
const onStdoutData = (chunk) => {
|
|
610
|
+
appendOutput("stdout", chunk);
|
|
611
|
+
};
|
|
612
|
+
const onStderrData = (chunk) => {
|
|
613
|
+
appendOutput("stderr", chunk);
|
|
614
|
+
};
|
|
615
|
+
const onClose = (exitCode, signal) => {
|
|
616
|
+
if (settled)
|
|
617
|
+
return;
|
|
618
|
+
settled = true;
|
|
619
|
+
cleanup();
|
|
620
|
+
resolve({
|
|
621
|
+
command,
|
|
622
|
+
stdout,
|
|
623
|
+
stderr,
|
|
624
|
+
exitCode: typeof exitCode === "number" ? exitCode : null,
|
|
625
|
+
...(signal ? { signal } : {})
|
|
626
|
+
});
|
|
627
|
+
};
|
|
628
|
+
const timer = options.timeout === undefined
|
|
629
|
+
? undefined
|
|
630
|
+
: setTimeout(() => {
|
|
631
|
+
fail(new RemoteCommandError(`Remote command timed out after ${options.timeout} ms.`, {
|
|
632
|
+
exitCode: null,
|
|
633
|
+
stdout,
|
|
634
|
+
stderr
|
|
635
|
+
}));
|
|
636
|
+
}, options.timeout);
|
|
637
|
+
stream.setEncoding("utf8");
|
|
638
|
+
stream.stderr.setEncoding("utf8");
|
|
639
|
+
stream.on("data", onStdoutData);
|
|
640
|
+
stream.stderr.on("data", onStderrData);
|
|
641
|
+
stream.once("error", onStreamError);
|
|
642
|
+
stream.stderr.once("error", onStreamError);
|
|
643
|
+
stream.once("close", onClose);
|
|
669
644
|
}
|
|
670
645
|
|
|
671
|
-
// src/client/walk.ts
|
|
672
|
-
import { readdir, stat as stat2 } from "fs/promises";
|
|
673
|
-
import path3 from "path";
|
|
674
|
-
|
|
675
|
-
// src/paths/remote-path.ts
|
|
676
|
-
import path2 from "path";
|
|
677
646
|
function assertRemotePath(inputPath, label = "remote path") {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
647
|
+
if (!inputPath || inputPath.trim().length === 0) {
|
|
648
|
+
throw new ValidationError(`Invalid ${label}: remote paths must not be empty.`, {
|
|
649
|
+
context: { path: inputPath }
|
|
650
|
+
});
|
|
651
|
+
}
|
|
683
652
|
}
|
|
684
653
|
function normalizeRemotePath(inputPath) {
|
|
685
|
-
|
|
686
|
-
|
|
654
|
+
assertRemotePath(inputPath);
|
|
655
|
+
return path.posix.normalize(inputPath.replace(/\\/g, "/"));
|
|
687
656
|
}
|
|
688
657
|
function restoreMsysConvertedRemotePath(inputPath) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
);
|
|
693
|
-
return match ? `/${match[1]}` : inputPath;
|
|
658
|
+
const normalized = inputPath.replace(/\\/g, "/");
|
|
659
|
+
const match = /^[A-Za-z]:\/(?:Program Files\/Git|Program Files \(x86\)\/Git|Git|msys64)\/(.+)$/i.exec(normalized);
|
|
660
|
+
return match ? `/${match[1]}` : inputPath;
|
|
694
661
|
}
|
|
695
662
|
function remoteJoin(...parts) {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
663
|
+
const joined = path.posix.join(...parts);
|
|
664
|
+
assertRemotePath(joined);
|
|
665
|
+
return joined;
|
|
699
666
|
}
|
|
700
667
|
function remoteDirname(inputPath) {
|
|
701
|
-
|
|
702
|
-
|
|
668
|
+
assertRemotePath(inputPath);
|
|
669
|
+
return path.posix.dirname(inputPath);
|
|
703
670
|
}
|
|
704
671
|
|
|
705
|
-
// src/client/walk.ts
|
|
706
672
|
async function walkLocalFiles(rootPath) {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
673
|
+
const resolvedRoot = resolveLocalPath(rootPath);
|
|
674
|
+
const stats = await stat(resolvedRoot);
|
|
675
|
+
if (stats.isFile()) {
|
|
676
|
+
return [
|
|
677
|
+
{
|
|
678
|
+
absolutePath: resolvedRoot,
|
|
679
|
+
relativePath: path.basename(resolvedRoot),
|
|
680
|
+
size: stats.size
|
|
681
|
+
}
|
|
682
|
+
];
|
|
683
|
+
}
|
|
684
|
+
const files = [];
|
|
685
|
+
async function visit(directory) {
|
|
686
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
687
|
+
for (const entry of entries) {
|
|
688
|
+
const absolutePath = path.join(directory, entry.name);
|
|
689
|
+
if (entry.isDirectory()) {
|
|
690
|
+
await visit(absolutePath);
|
|
691
|
+
}
|
|
692
|
+
else if (entry.isFile()) {
|
|
693
|
+
const entryStats = await stat(absolutePath);
|
|
694
|
+
files.push({
|
|
695
|
+
absolutePath,
|
|
696
|
+
relativePath: path.relative(resolvedRoot, absolutePath),
|
|
697
|
+
size: entryStats.size
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
733
701
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
return files;
|
|
702
|
+
await visit(resolvedRoot);
|
|
703
|
+
return files;
|
|
737
704
|
}
|
|
738
705
|
async function walkRemoteFiles(transport, rootPath) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
706
|
+
const rootEntries = await transport.list(rootPath);
|
|
707
|
+
const files = [];
|
|
708
|
+
async function visit(directory, relativeBase, entries) {
|
|
709
|
+
for (const entry of entries) {
|
|
710
|
+
const fullPath = remoteJoin(directory, entry.name);
|
|
711
|
+
const relativePath = relativeBase ? remoteJoin(relativeBase, entry.name) : entry.name;
|
|
712
|
+
if (entry.type === "d") {
|
|
713
|
+
await visit(fullPath, relativePath, await transport.list(fullPath));
|
|
714
|
+
}
|
|
715
|
+
else if (entry.type === "-") {
|
|
716
|
+
files.push({
|
|
717
|
+
remotePath: fullPath,
|
|
718
|
+
relativePath,
|
|
719
|
+
size: entry.size
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
754
723
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
return files;
|
|
724
|
+
await visit(rootPath, "", rootEntries);
|
|
725
|
+
return files;
|
|
758
726
|
}
|
|
759
727
|
|
|
760
|
-
// src/config/validation.ts
|
|
761
|
-
import { access as access3 } from "fs/promises";
|
|
762
|
-
|
|
763
|
-
// src/config/post-upload-commands.ts
|
|
764
728
|
function isPostUploadCommands(value) {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
);
|
|
729
|
+
return (Array.isArray(value) &&
|
|
730
|
+
value.every((command) => typeof command === "string" && command.trim().length > 0));
|
|
768
731
|
}
|
|
769
732
|
function getPostUploadCommands(source) {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
return commands;
|
|
733
|
+
const commands = readPostUploadCommands(source);
|
|
734
|
+
if (commands === undefined) {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
if (!isPostUploadCommands(commands)) {
|
|
738
|
+
throw new ValidationError("postUploadCommands must be an array of non-empty command strings.");
|
|
739
|
+
}
|
|
740
|
+
return commands;
|
|
780
741
|
}
|
|
781
742
|
function readPostUploadCommands(source) {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
743
|
+
if (typeof source !== "object" || source === null) {
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
746
|
+
return source
|
|
747
|
+
.postUploadCommands;
|
|
786
748
|
}
|
|
787
749
|
function clearPostUploadCommands(source) {
|
|
788
|
-
|
|
750
|
+
source.postUploadCommands =
|
|
751
|
+
undefined;
|
|
789
752
|
}
|
|
790
753
|
|
|
791
|
-
// src/config/validation.ts
|
|
792
754
|
function validatePort(port) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
755
|
+
if (port === undefined) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
759
|
+
throw new ValidationError("Port must be an integer between 1 and 65535.", {
|
|
760
|
+
context: { port }
|
|
761
|
+
});
|
|
762
|
+
}
|
|
801
763
|
}
|
|
802
764
|
function validateTimeout(timeout) {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
765
|
+
if (timeout === undefined) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
769
|
+
throw new ValidationError("Timeout must be a positive number.", {
|
|
770
|
+
context: { timeout }
|
|
771
|
+
});
|
|
772
|
+
}
|
|
811
773
|
}
|
|
812
774
|
async function validateServerOptions(options) {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
775
|
+
if (!options.host) {
|
|
776
|
+
throw new ValidationError("Host is required.");
|
|
777
|
+
}
|
|
778
|
+
if (!options.username) {
|
|
779
|
+
throw new ValidationError("Username is required.");
|
|
780
|
+
}
|
|
781
|
+
validatePort(options.port);
|
|
782
|
+
validateTimeout(options.timeout);
|
|
783
|
+
if (options.privateKeyFile) {
|
|
784
|
+
try {
|
|
785
|
+
await access(resolveLocalPath(options.privateKeyFile));
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
throw new ValidationError(`Private key file does not exist: ${options.privateKeyFile}`, {
|
|
789
|
+
cause: error,
|
|
790
|
+
context: { privateKeyFile: options.privateKeyFile }
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const hasAuthentication = Boolean(options.password) ||
|
|
795
|
+
Boolean(options.privateKey) ||
|
|
796
|
+
Boolean(options.privateKeyFile) ||
|
|
797
|
+
Boolean(options.agent) ||
|
|
798
|
+
Boolean(process.env.SSH_AUTH_SOCK);
|
|
799
|
+
if (!hasAuthentication) {
|
|
800
|
+
throw new ValidationError("Authentication is required. Provide a password, private key, private key file, or SSH agent.");
|
|
801
|
+
}
|
|
837
802
|
}
|
|
838
803
|
async function validateUploadOptions(options) {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
804
|
+
if (!options.localPath) {
|
|
805
|
+
throw new ValidationError("Missing localPath.");
|
|
806
|
+
}
|
|
807
|
+
if (!options.remotePath) {
|
|
808
|
+
throw new ValidationError("Missing remotePath.");
|
|
809
|
+
}
|
|
810
|
+
assertRemotePath(options.remotePath, "remotePath");
|
|
811
|
+
validateTimeout(options.timeout);
|
|
812
|
+
getPostUploadCommands(options);
|
|
813
|
+
await assertLocalPathExists(options.localPath);
|
|
814
|
+
if (!options.dryRun) {
|
|
815
|
+
await validateServerOptions(options);
|
|
816
|
+
}
|
|
852
817
|
}
|
|
853
818
|
async function validateDownloadOptions(options) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
819
|
+
if (!options.remotePath) {
|
|
820
|
+
throw new ValidationError("Missing remotePath.");
|
|
821
|
+
}
|
|
822
|
+
if (!options.localPath) {
|
|
823
|
+
throw new ValidationError("Missing localPath.");
|
|
824
|
+
}
|
|
825
|
+
assertRemotePath(options.remotePath, "remotePath");
|
|
826
|
+
validateTimeout(options.timeout);
|
|
827
|
+
if (getPostUploadCommands(options)?.length) {
|
|
828
|
+
throw new ValidationError("Post-upload commands are not supported for downloads.");
|
|
829
|
+
}
|
|
830
|
+
await assertDownloadDestination(options.localPath, options.createDirectories ?? true);
|
|
831
|
+
if (!options.dryRun) {
|
|
832
|
+
await validateServerOptions(options);
|
|
833
|
+
}
|
|
869
834
|
}
|
|
870
835
|
|
|
871
|
-
// src/client/client.ts
|
|
872
836
|
function percentage(transferredBytes, totalBytes) {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
837
|
+
if (!totalBytes || totalBytes <= 0) {
|
|
838
|
+
return undefined;
|
|
839
|
+
}
|
|
840
|
+
return Math.min(100, Math.round((transferredBytes / totalBytes) * 100));
|
|
877
841
|
}
|
|
878
842
|
function mergeOptions(serverOptions, transferOptions) {
|
|
879
|
-
|
|
843
|
+
return { ...serverOptions, ...(transferOptions ?? {}) };
|
|
880
844
|
}
|
|
881
845
|
function shouldCreateDirectories(options) {
|
|
882
|
-
|
|
846
|
+
return options.createDirectories ?? true;
|
|
883
847
|
}
|
|
884
848
|
function hasTrailingPathSeparator(inputPath) {
|
|
885
|
-
|
|
849
|
+
return /[\\/]$/.test(inputPath);
|
|
886
850
|
}
|
|
887
851
|
function trimmedRemoteDirectory(remotePath) {
|
|
888
|
-
|
|
852
|
+
return remotePath.replace(/\/+$/g, "") || "/";
|
|
889
853
|
}
|
|
890
854
|
function remoteBasename(remotePath) {
|
|
891
|
-
|
|
855
|
+
return path.posix.basename(trimmedRemoteDirectory(remotePath));
|
|
892
856
|
}
|
|
893
857
|
async function isExistingLocalDirectory(localPath) {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
858
|
+
try {
|
|
859
|
+
return (await stat(localPath)).isDirectory();
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
899
864
|
}
|
|
900
865
|
async function ensureLocalDirectory(localPath, createParents = false) {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
866
|
+
try {
|
|
867
|
+
const stats = await stat(localPath);
|
|
868
|
+
if (stats.isDirectory()) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
throw new TransferError(`Local destination already exists and is not a directory: ${localPath}`, {
|
|
872
|
+
context: { localPath }
|
|
873
|
+
});
|
|
905
874
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
await validateServerOptions(this.serverOptions);
|
|
929
|
-
await this.transport.connect(this.serverOptions);
|
|
930
|
-
this.connected = true;
|
|
931
|
-
}
|
|
932
|
-
async upload(localPath, remotePath, options = {}) {
|
|
933
|
-
const uploadOptions = {
|
|
934
|
-
...mergeOptions(this.serverOptions, options),
|
|
935
|
-
localPath,
|
|
936
|
-
remotePath
|
|
937
|
-
};
|
|
938
|
-
await validateUploadOptions(uploadOptions);
|
|
939
|
-
if (options.dryRun) {
|
|
940
|
-
return [];
|
|
941
|
-
}
|
|
942
|
-
await this.ensureConnected();
|
|
943
|
-
await this.uploadWithTransport(uploadOptions);
|
|
944
|
-
const results = [];
|
|
945
|
-
for (const command of getPostUploadCommands(options) ?? []) {
|
|
946
|
-
results.push(await this.exec(command));
|
|
947
|
-
}
|
|
948
|
-
return results;
|
|
949
|
-
}
|
|
950
|
-
async download(remotePath, localPath, options = {}) {
|
|
951
|
-
const downloadOptions = {
|
|
952
|
-
...mergeOptions(this.serverOptions, options),
|
|
953
|
-
remotePath,
|
|
954
|
-
localPath
|
|
955
|
-
};
|
|
956
|
-
await validateDownloadOptions(downloadOptions);
|
|
957
|
-
if (options.dryRun) {
|
|
958
|
-
return;
|
|
875
|
+
catch (error) {
|
|
876
|
+
if (error instanceof TransferError) {
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
await mkdir(localPath, { recursive: createParents });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
class ScpNextClientImpl {
|
|
883
|
+
serverOptions;
|
|
884
|
+
transport;
|
|
885
|
+
commandExecutor;
|
|
886
|
+
connected = false;
|
|
887
|
+
commandExecutorConnected = false;
|
|
888
|
+
constructor(serverOptions, dependencies = {}) {
|
|
889
|
+
this.serverOptions = serverOptions;
|
|
890
|
+
this.transport = dependencies.transport ?? new Ssh2SftpTransport();
|
|
891
|
+
this.commandExecutor = dependencies.commandExecutor ?? new SshCommandExecutor();
|
|
892
|
+
}
|
|
893
|
+
async connect() {
|
|
894
|
+
await validateServerOptions(this.serverOptions);
|
|
895
|
+
await this.transport.connect(this.serverOptions);
|
|
896
|
+
this.connected = true;
|
|
959
897
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
898
|
+
async upload(localPath, remotePath, options = {}) {
|
|
899
|
+
const uploadOptions = {
|
|
900
|
+
...mergeOptions(this.serverOptions, options),
|
|
901
|
+
localPath,
|
|
902
|
+
remotePath
|
|
903
|
+
};
|
|
904
|
+
await validateUploadOptions(uploadOptions);
|
|
905
|
+
if (options.dryRun) {
|
|
906
|
+
return [];
|
|
907
|
+
}
|
|
908
|
+
await this.ensureConnected();
|
|
909
|
+
await this.uploadWithTransport(uploadOptions);
|
|
910
|
+
const results = [];
|
|
911
|
+
for (const command of getPostUploadCommands(options) ?? []) {
|
|
912
|
+
results.push(await this.exec(command));
|
|
913
|
+
}
|
|
914
|
+
return results;
|
|
915
|
+
}
|
|
916
|
+
async download(remotePath, localPath, options = {}) {
|
|
917
|
+
const downloadOptions = {
|
|
918
|
+
...mergeOptions(this.serverOptions, options),
|
|
919
|
+
remotePath,
|
|
920
|
+
localPath
|
|
921
|
+
};
|
|
922
|
+
await validateDownloadOptions(downloadOptions);
|
|
923
|
+
if (options.dryRun) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
await this.ensureConnected();
|
|
927
|
+
await this.downloadWithTransport(downloadOptions);
|
|
966
928
|
}
|
|
967
|
-
|
|
968
|
-
|
|
929
|
+
async exec(command, options = {}) {
|
|
930
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
931
|
+
throw new ValidationError("Remote command must not be empty.");
|
|
932
|
+
}
|
|
933
|
+
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0)) {
|
|
934
|
+
throw new ValidationError("Command timeout must be a positive number.");
|
|
935
|
+
}
|
|
936
|
+
if (options.maxBuffer !== undefined &&
|
|
937
|
+
(!Number.isInteger(options.maxBuffer) || options.maxBuffer <= 0)) {
|
|
938
|
+
throw new ValidationError("Command maxBuffer must be a positive integer.");
|
|
939
|
+
}
|
|
940
|
+
if (options.failOnStderr !== undefined &&
|
|
941
|
+
typeof options.failOnStderr !== "boolean") {
|
|
942
|
+
throw new ValidationError("Command failOnStderr must be a boolean.");
|
|
943
|
+
}
|
|
944
|
+
await this.ensureCommandExecutorConnected();
|
|
945
|
+
let result;
|
|
946
|
+
try {
|
|
947
|
+
result = await this.commandExecutor.exec(command, options);
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
if (error instanceof RemoteCommandError) {
|
|
951
|
+
throw new RemoteCommandError(error.message, {
|
|
952
|
+
exitCode: error.exitCode,
|
|
953
|
+
signal: error.signal,
|
|
954
|
+
stdout: redactKnownSensitiveValues(error.stdout, this.serverOptions),
|
|
955
|
+
stderr: redactKnownSensitiveValues(error.stderr, this.serverOptions)
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
throw error;
|
|
959
|
+
}
|
|
960
|
+
if (result.exitCode !== 0 || (options.failOnStderr && result.stderr.length > 0)) {
|
|
961
|
+
const reason = result.exitCode === null
|
|
962
|
+
? "Remote command ended without an exit code."
|
|
963
|
+
: result.exitCode !== 0
|
|
964
|
+
? `Remote command failed with exit code ${String(result.exitCode)}.`
|
|
965
|
+
: "Remote command wrote to stderr.";
|
|
966
|
+
throw new RemoteCommandError(reason, {
|
|
967
|
+
exitCode: result.exitCode,
|
|
968
|
+
signal: result.signal,
|
|
969
|
+
stdout: redactKnownSensitiveValues(result.stdout, this.serverOptions),
|
|
970
|
+
stderr: redactKnownSensitiveValues(result.stderr, this.serverOptions)
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
return result;
|
|
974
|
+
}
|
|
975
|
+
async close() {
|
|
976
|
+
const closures = [];
|
|
977
|
+
if (this.connected)
|
|
978
|
+
closures.push(this.transport.close());
|
|
979
|
+
if (this.commandExecutorConnected)
|
|
980
|
+
closures.push(this.commandExecutor.close());
|
|
981
|
+
const results = await Promise.allSettled(closures);
|
|
982
|
+
this.connected = false;
|
|
983
|
+
this.commandExecutorConnected = false;
|
|
984
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
985
|
+
if (failure) {
|
|
986
|
+
throw failure.reason;
|
|
987
|
+
}
|
|
969
988
|
}
|
|
970
|
-
|
|
971
|
-
|
|
989
|
+
async ensureConnected() {
|
|
990
|
+
if (!this.connected) {
|
|
991
|
+
await this.connect();
|
|
992
|
+
}
|
|
972
993
|
}
|
|
973
|
-
|
|
974
|
-
|
|
994
|
+
async ensureCommandExecutorConnected() {
|
|
995
|
+
if (!this.commandExecutorConnected) {
|
|
996
|
+
await validateServerOptions(this.serverOptions);
|
|
997
|
+
await this.commandExecutor.connect(this.serverOptions);
|
|
998
|
+
this.commandExecutorConnected = true;
|
|
999
|
+
}
|
|
975
1000
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1001
|
+
async uploadWithTransport(options) {
|
|
1002
|
+
const localKind = await getLocalPathKind(options.localPath);
|
|
1003
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1004
|
+
if (localKind === "directory" && !options.recursive) {
|
|
1005
|
+
throw new ValidationError("Uploading a directory requires recursive mode.");
|
|
1006
|
+
}
|
|
1007
|
+
if (localKind === "file") {
|
|
1008
|
+
const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
|
|
1009
|
+
const totalBytes = undefined;
|
|
1010
|
+
await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
|
|
1011
|
+
options.onProgress?.({
|
|
1012
|
+
operation: "upload",
|
|
1013
|
+
source: options.localPath,
|
|
1014
|
+
destination: options.remotePath,
|
|
1015
|
+
transferredBytes: step.transferredBytes,
|
|
1016
|
+
totalBytes: step.totalBytes || totalBytes,
|
|
1017
|
+
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
1018
|
+
currentFile: options.localPath
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const files = await walkLocalFiles(options.localPath);
|
|
1024
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
1025
|
+
let completedBytes = 0;
|
|
1026
|
+
let completedFiles = 0;
|
|
1027
|
+
const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(options, remotePath);
|
|
1028
|
+
for (const file of files) {
|
|
1029
|
+
const destinationFile = remoteJoin(destinationRoot, file.relativePath.split(path.sep).join("/"));
|
|
1030
|
+
await this.ensureRemoteParent(destinationFile, true);
|
|
1031
|
+
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
1032
|
+
await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
|
|
1033
|
+
const transferredBytes = completedBytes + step.transferredBytes;
|
|
1034
|
+
options.onProgress?.({
|
|
1035
|
+
operation: "upload",
|
|
1036
|
+
source: options.localPath,
|
|
1037
|
+
destination: options.remotePath,
|
|
1038
|
+
transferredBytes,
|
|
1039
|
+
totalBytes,
|
|
1040
|
+
percentage: percentage(transferredBytes, totalBytes),
|
|
1041
|
+
currentFile: file.relativePath,
|
|
1042
|
+
completedFiles,
|
|
1043
|
+
totalFiles: files.length
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
completedBytes += file.size;
|
|
1047
|
+
completedFiles += 1;
|
|
1048
|
+
}
|
|
999
1049
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1093
|
-
context: { remotePath }
|
|
1094
|
-
});
|
|
1095
|
-
}
|
|
1096
|
-
const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
|
|
1097
|
-
if (!remoteKind) {
|
|
1098
|
-
if (!shouldCreateDirectories(options)) {
|
|
1099
|
-
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
1100
|
-
context: { remotePath }
|
|
1101
|
-
});
|
|
1102
|
-
}
|
|
1103
|
-
await this.transport.mkdir(remoteDirectory, true);
|
|
1104
|
-
}
|
|
1105
|
-
const destinationFile = remoteJoin(
|
|
1106
|
-
remoteDirectory,
|
|
1107
|
-
path4.basename(resolveLocalPath(options.localPath))
|
|
1108
|
-
);
|
|
1109
|
-
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
1110
|
-
return destinationFile;
|
|
1111
|
-
}
|
|
1112
|
-
async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
|
|
1113
|
-
const remoteKind = await this.transport.exists(remotePath);
|
|
1114
|
-
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
1115
|
-
const localDirectoryName = path4.basename(resolveLocalPath(options.localPath));
|
|
1116
|
-
if (remoteKind && remoteKind !== "d") {
|
|
1117
|
-
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1118
|
-
context: { remotePath }
|
|
1119
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
if (isDirectoryLikeDestination) {
|
|
1122
|
-
const remoteDirectory = trimmedRemoteDirectory(remotePath);
|
|
1123
|
-
if (!remoteKind) {
|
|
1124
|
-
if (!shouldCreateDirectories(options)) {
|
|
1125
|
-
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
1126
|
-
context: { remotePath }
|
|
1127
|
-
});
|
|
1128
|
-
}
|
|
1129
|
-
await this.transport.mkdir(remoteDirectory, true);
|
|
1130
|
-
}
|
|
1131
|
-
const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
|
|
1132
|
-
await this.ensureRemoteDirectory(destinationRoot);
|
|
1133
|
-
return destinationRoot;
|
|
1134
|
-
}
|
|
1135
|
-
if (!remoteKind) {
|
|
1136
|
-
if (!shouldCreateDirectories(options)) {
|
|
1137
|
-
throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
|
|
1138
|
-
context: { remotePath }
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1141
|
-
await this.transport.mkdir(remotePath, true);
|
|
1142
|
-
}
|
|
1143
|
-
return remotePath;
|
|
1144
|
-
}
|
|
1145
|
-
async downloadWithTransport(options) {
|
|
1146
|
-
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1147
|
-
const remoteKind = await this.transport.exists(remotePath);
|
|
1148
|
-
if (!remoteKind) {
|
|
1149
|
-
throw new TransferError(`Remote path does not exist: ${options.remotePath}`, {
|
|
1150
|
-
context: { remotePath: options.remotePath }
|
|
1151
|
-
});
|
|
1152
|
-
}
|
|
1153
|
-
if (remoteKind === "d") {
|
|
1154
|
-
if (!options.recursive) {
|
|
1155
|
-
throw new ValidationError("Downloading a directory requires recursive mode.");
|
|
1156
|
-
}
|
|
1157
|
-
await this.downloadDirectory(options);
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
await this.downloadSingleFile(options, remotePath);
|
|
1161
|
-
}
|
|
1162
|
-
async downloadSingleFile(options, remotePath) {
|
|
1163
|
-
const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
|
|
1164
|
-
await this.assertLocalOverwrite(localPath, options.overwrite);
|
|
1165
|
-
if (shouldCreateDirectories(options)) {
|
|
1166
|
-
await mkdir2(path4.dirname(localPath), { recursive: true });
|
|
1167
|
-
}
|
|
1168
|
-
await this.transport.downloadFile(remotePath, localPath, (step) => {
|
|
1169
|
-
options.onProgress?.({
|
|
1170
|
-
operation: "download",
|
|
1171
|
-
source: options.remotePath,
|
|
1172
|
-
destination: options.localPath,
|
|
1173
|
-
transferredBytes: step.transferredBytes,
|
|
1174
|
-
totalBytes: step.totalBytes,
|
|
1175
|
-
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
1176
|
-
currentFile: options.remotePath
|
|
1177
|
-
});
|
|
1178
|
-
});
|
|
1179
|
-
}
|
|
1180
|
-
async downloadDirectory(options) {
|
|
1181
|
-
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1182
|
-
const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
|
|
1183
|
-
const files = await walkRemoteFiles(this.transport, remotePath);
|
|
1184
|
-
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
1185
|
-
let completedBytes = 0;
|
|
1186
|
-
let completedFiles = 0;
|
|
1187
|
-
await ensureLocalDirectory(localRoot, shouldCreateDirectories(options));
|
|
1188
|
-
for (const file of files) {
|
|
1189
|
-
const localFile = path4.join(localRoot, ...file.relativePath.split("/"));
|
|
1190
|
-
await this.assertLocalOverwrite(localFile, options.overwrite);
|
|
1191
|
-
await mkdir2(path4.dirname(localFile), { recursive: true });
|
|
1192
|
-
await this.transport.downloadFile(file.remotePath, localFile, (step) => {
|
|
1193
|
-
const transferredBytes = completedBytes + step.transferredBytes;
|
|
1194
|
-
options.onProgress?.({
|
|
1195
|
-
operation: "download",
|
|
1196
|
-
source: options.remotePath,
|
|
1197
|
-
destination: options.localPath,
|
|
1198
|
-
transferredBytes,
|
|
1199
|
-
totalBytes,
|
|
1200
|
-
percentage: percentage(transferredBytes, totalBytes),
|
|
1201
|
-
currentFile: file.relativePath,
|
|
1202
|
-
completedFiles,
|
|
1203
|
-
totalFiles: files.length
|
|
1204
|
-
});
|
|
1205
|
-
});
|
|
1206
|
-
completedBytes += file.size;
|
|
1207
|
-
completedFiles += 1;
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
async resolveLocalDestinationForRemoteFile(options, remotePath) {
|
|
1211
|
-
const localDestination = resolveLocalPath(options.localPath);
|
|
1212
|
-
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
|
|
1213
|
-
if (!isDirectoryLikeDestination) {
|
|
1214
|
-
return localDestination;
|
|
1215
|
-
}
|
|
1216
|
-
if (!await isExistingLocalDirectory(localDestination)) {
|
|
1217
|
-
if (!shouldCreateDirectories(options)) {
|
|
1218
|
-
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1219
|
-
context: { localPath: options.localPath }
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
await mkdir2(localDestination, { recursive: true });
|
|
1223
|
-
}
|
|
1224
|
-
return path4.join(localDestination, remoteBasename(remotePath));
|
|
1225
|
-
}
|
|
1226
|
-
async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
|
|
1227
|
-
const localDestination = resolveLocalPath(options.localPath);
|
|
1228
|
-
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
|
|
1229
|
-
if (!isDirectoryLikeDestination) {
|
|
1230
|
-
return localDestination;
|
|
1231
|
-
}
|
|
1232
|
-
if (!await isExistingLocalDirectory(localDestination)) {
|
|
1233
|
-
if (!shouldCreateDirectories(options)) {
|
|
1234
|
-
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1235
|
-
context: { localPath: options.localPath }
|
|
1050
|
+
async resolveRemoteDestinationForLocalFile(options, remotePath) {
|
|
1051
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
1052
|
+
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
1053
|
+
if (!isDirectoryLikeDestination) {
|
|
1054
|
+
await this.ensureRemoteParent(remotePath, shouldCreateDirectories(options));
|
|
1055
|
+
await this.assertRemoteOverwrite(remotePath, options.overwrite);
|
|
1056
|
+
return remotePath;
|
|
1057
|
+
}
|
|
1058
|
+
if (remoteKind && remoteKind !== "d") {
|
|
1059
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1060
|
+
context: { remotePath }
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
|
|
1064
|
+
if (!remoteKind) {
|
|
1065
|
+
if (!shouldCreateDirectories(options)) {
|
|
1066
|
+
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
1067
|
+
context: { remotePath }
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
await this.transport.mkdir(remoteDirectory, true);
|
|
1071
|
+
}
|
|
1072
|
+
const destinationFile = remoteJoin(remoteDirectory, path.basename(resolveLocalPath(options.localPath)));
|
|
1073
|
+
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
1074
|
+
return destinationFile;
|
|
1075
|
+
}
|
|
1076
|
+
async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
|
|
1077
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
1078
|
+
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
1079
|
+
const localDirectoryName = path.basename(resolveLocalPath(options.localPath));
|
|
1080
|
+
if (remoteKind && remoteKind !== "d") {
|
|
1081
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1082
|
+
context: { remotePath }
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
if (isDirectoryLikeDestination) {
|
|
1086
|
+
const remoteDirectory = trimmedRemoteDirectory(remotePath);
|
|
1087
|
+
if (!remoteKind) {
|
|
1088
|
+
if (!shouldCreateDirectories(options)) {
|
|
1089
|
+
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
1090
|
+
context: { remotePath }
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
await this.transport.mkdir(remoteDirectory, true);
|
|
1094
|
+
}
|
|
1095
|
+
const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
|
|
1096
|
+
await this.ensureRemoteDirectory(destinationRoot);
|
|
1097
|
+
return destinationRoot;
|
|
1098
|
+
}
|
|
1099
|
+
if (!remoteKind) {
|
|
1100
|
+
if (!shouldCreateDirectories(options)) {
|
|
1101
|
+
throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
|
|
1102
|
+
context: { remotePath }
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
await this.transport.mkdir(remotePath, true);
|
|
1106
|
+
}
|
|
1107
|
+
return remotePath;
|
|
1108
|
+
}
|
|
1109
|
+
async downloadWithTransport(options) {
|
|
1110
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1111
|
+
const remoteKind = await this.transport.exists(remotePath);
|
|
1112
|
+
if (!remoteKind) {
|
|
1113
|
+
throw new TransferError(`Remote path does not exist: ${options.remotePath}`, {
|
|
1114
|
+
context: { remotePath: options.remotePath }
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
if (remoteKind === "d") {
|
|
1118
|
+
if (!options.recursive) {
|
|
1119
|
+
throw new ValidationError("Downloading a directory requires recursive mode.");
|
|
1120
|
+
}
|
|
1121
|
+
await this.downloadDirectory(options);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
await this.downloadSingleFile(options, remotePath);
|
|
1125
|
+
}
|
|
1126
|
+
async downloadSingleFile(options, remotePath) {
|
|
1127
|
+
const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
|
|
1128
|
+
await this.assertLocalOverwrite(localPath, options.overwrite);
|
|
1129
|
+
if (shouldCreateDirectories(options)) {
|
|
1130
|
+
await mkdir(path.dirname(localPath), { recursive: true });
|
|
1131
|
+
}
|
|
1132
|
+
await this.transport.downloadFile(remotePath, localPath, (step) => {
|
|
1133
|
+
options.onProgress?.({
|
|
1134
|
+
operation: "download",
|
|
1135
|
+
source: options.remotePath,
|
|
1136
|
+
destination: options.localPath,
|
|
1137
|
+
transferredBytes: step.transferredBytes,
|
|
1138
|
+
totalBytes: step.totalBytes,
|
|
1139
|
+
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
1140
|
+
currentFile: options.remotePath
|
|
1141
|
+
});
|
|
1236
1142
|
});
|
|
1237
|
-
}
|
|
1238
|
-
await mkdir2(localDestination, { recursive: true });
|
|
1239
1143
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1144
|
+
async downloadDirectory(options) {
|
|
1145
|
+
const remotePath = normalizeRemotePath(options.remotePath);
|
|
1146
|
+
const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
|
|
1147
|
+
const files = await walkRemoteFiles(this.transport, remotePath);
|
|
1148
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
1149
|
+
let completedBytes = 0;
|
|
1150
|
+
let completedFiles = 0;
|
|
1151
|
+
await ensureLocalDirectory(localRoot, shouldCreateDirectories(options));
|
|
1152
|
+
for (const file of files) {
|
|
1153
|
+
const localFile = path.join(localRoot, ...file.relativePath.split("/"));
|
|
1154
|
+
await this.assertLocalOverwrite(localFile, options.overwrite);
|
|
1155
|
+
await mkdir(path.dirname(localFile), { recursive: true });
|
|
1156
|
+
await this.transport.downloadFile(file.remotePath, localFile, (step) => {
|
|
1157
|
+
const transferredBytes = completedBytes + step.transferredBytes;
|
|
1158
|
+
options.onProgress?.({
|
|
1159
|
+
operation: "download",
|
|
1160
|
+
source: options.remotePath,
|
|
1161
|
+
destination: options.localPath,
|
|
1162
|
+
transferredBytes,
|
|
1163
|
+
totalBytes,
|
|
1164
|
+
percentage: percentage(transferredBytes, totalBytes),
|
|
1165
|
+
currentFile: file.relativePath,
|
|
1166
|
+
completedFiles,
|
|
1167
|
+
totalFiles: files.length
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
completedBytes += file.size;
|
|
1171
|
+
completedFiles += 1;
|
|
1172
|
+
}
|
|
1246
1173
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
context: { remotePath }
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
1265
|
-
if (!exists) {
|
|
1266
|
-
await this.transport.mkdir(remotePath, true);
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
async assertRemoteOverwrite(remotePath, overwrite = false) {
|
|
1270
|
-
if (overwrite) {
|
|
1271
|
-
return;
|
|
1272
|
-
}
|
|
1273
|
-
const exists = await this.transport.exists(remotePath);
|
|
1274
|
-
if (exists) {
|
|
1275
|
-
throw new TransferError(
|
|
1276
|
-
`Remote destination already exists: ${remotePath}. Use --overwrite to overwrite it.`,
|
|
1277
|
-
{
|
|
1278
|
-
context: { remotePath }
|
|
1279
|
-
}
|
|
1280
|
-
);
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
async assertLocalOverwrite(localPath, overwrite = false) {
|
|
1284
|
-
if (overwrite) {
|
|
1285
|
-
return;
|
|
1174
|
+
async resolveLocalDestinationForRemoteFile(options, remotePath) {
|
|
1175
|
+
const localDestination = resolveLocalPath(options.localPath);
|
|
1176
|
+
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || (await isExistingLocalDirectory(localDestination));
|
|
1177
|
+
if (!isDirectoryLikeDestination) {
|
|
1178
|
+
return localDestination;
|
|
1179
|
+
}
|
|
1180
|
+
if (!(await isExistingLocalDirectory(localDestination))) {
|
|
1181
|
+
if (!shouldCreateDirectories(options)) {
|
|
1182
|
+
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1183
|
+
context: { localPath: options.localPath }
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
await mkdir(localDestination, { recursive: true });
|
|
1187
|
+
}
|
|
1188
|
+
return path.join(localDestination, remoteBasename(remotePath));
|
|
1286
1189
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1190
|
+
async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
|
|
1191
|
+
const localDestination = resolveLocalPath(options.localPath);
|
|
1192
|
+
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || (await isExistingLocalDirectory(localDestination));
|
|
1193
|
+
if (!isDirectoryLikeDestination) {
|
|
1194
|
+
return localDestination;
|
|
1195
|
+
}
|
|
1196
|
+
if (!(await isExistingLocalDirectory(localDestination))) {
|
|
1197
|
+
if (!shouldCreateDirectories(options)) {
|
|
1198
|
+
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
1199
|
+
context: { localPath: options.localPath }
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
await mkdir(localDestination, { recursive: true });
|
|
1203
|
+
}
|
|
1204
|
+
return path.join(localDestination, remoteBasename(remotePath));
|
|
1296
1205
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1206
|
+
async ensureRemoteParent(remotePath, createDirectories = false) {
|
|
1207
|
+
const parent = remoteDirname(remotePath);
|
|
1208
|
+
if (parent === "." || parent === "/") {
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (createDirectories) {
|
|
1212
|
+
await this.transport.mkdir(parent, true);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const exists = await this.transport.exists(parent);
|
|
1216
|
+
if (exists !== "d") {
|
|
1217
|
+
throw new TransferError(`Remote destination parent does not exist: ${parent}`, {
|
|
1218
|
+
context: { remotePath }
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
async ensureRemoteDirectory(remotePath) {
|
|
1223
|
+
const exists = await this.transport.exists(remotePath);
|
|
1224
|
+
if (exists && exists !== "d") {
|
|
1225
|
+
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
1226
|
+
context: { remotePath }
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
if (!exists) {
|
|
1230
|
+
await this.transport.mkdir(remotePath, true);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
async assertRemoteOverwrite(remotePath, overwrite = false) {
|
|
1234
|
+
if (overwrite) {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const exists = await this.transport.exists(remotePath);
|
|
1238
|
+
if (exists) {
|
|
1239
|
+
throw new TransferError(`Remote destination already exists: ${remotePath}. Use --overwrite to overwrite it.`, {
|
|
1240
|
+
context: { remotePath }
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
async assertLocalOverwrite(localPath, overwrite = false) {
|
|
1245
|
+
if (overwrite) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
try {
|
|
1249
|
+
await access(localPath);
|
|
1250
|
+
throw new TransferError(`Local destination already exists: ${localPath}`, {
|
|
1251
|
+
context: { localPath }
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
if (error instanceof TransferError) {
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1299
1261
|
|
|
1300
|
-
// src/client/download.ts
|
|
1301
1262
|
async function download(options) {
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1263
|
+
const client = new ScpNextClientImpl(options);
|
|
1264
|
+
try {
|
|
1265
|
+
await client.download(options.remotePath, options.localPath, options);
|
|
1266
|
+
}
|
|
1267
|
+
finally {
|
|
1268
|
+
await client.close();
|
|
1269
|
+
}
|
|
1308
1270
|
}
|
|
1309
1271
|
|
|
1310
|
-
// src/client/upload.ts
|
|
1311
1272
|
async function upload(options) {
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
try {
|
|
1315
|
-
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1316
|
-
} catch (error) {
|
|
1273
|
+
const client = new ScpNextClientImpl(options);
|
|
1274
|
+
let result;
|
|
1317
1275
|
try {
|
|
1318
|
-
|
|
1319
|
-
} catch {
|
|
1276
|
+
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1320
1277
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
try {
|
|
1280
|
+
await client.close();
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
// Preserve the upload or command failure as the primary error.
|
|
1284
|
+
}
|
|
1285
|
+
throw error;
|
|
1286
|
+
}
|
|
1287
|
+
await client.close();
|
|
1288
|
+
return result;
|
|
1325
1289
|
}
|
|
1326
1290
|
|
|
1327
|
-
// src/cli/commands/download.ts
|
|
1328
|
-
import { Command } from "commander";
|
|
1329
|
-
|
|
1330
|
-
// src/cli/operands.ts
|
|
1331
1291
|
function usageFor(operation) {
|
|
1332
|
-
|
|
1292
|
+
return `scp-next ${operation} <source> <destination> [options]`;
|
|
1333
1293
|
}
|
|
1334
1294
|
function assertSourceDestination(operation, source, destination) {
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
${usageFor(operation)}`);
|
|
1340
|
-
|
|
1341
|
-
if (!destination) {
|
|
1342
|
-
throw new ValidationError(`Missing destination path.
|
|
1343
|
-
|
|
1344
|
-
Usage:
|
|
1345
|
-
${usageFor(operation)}`);
|
|
1346
|
-
}
|
|
1295
|
+
if (!source) {
|
|
1296
|
+
throw new ValidationError(`Missing source path.\n\nUsage:\n ${usageFor(operation)}`);
|
|
1297
|
+
}
|
|
1298
|
+
if (!destination) {
|
|
1299
|
+
throw new ValidationError(`Missing destination path.\n\nUsage:\n ${usageFor(operation)}`);
|
|
1300
|
+
}
|
|
1347
1301
|
}
|
|
1348
1302
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
if (!Number.isFinite(parsed)) {
|
|
1359
|
-
throw new Error(`${name} must be a number.`);
|
|
1360
|
-
}
|
|
1361
|
-
return parsed;
|
|
1303
|
+
function parseInteger$1(value, name) {
|
|
1304
|
+
if (!value) {
|
|
1305
|
+
return undefined;
|
|
1306
|
+
}
|
|
1307
|
+
const parsed = Number.parseInt(value, 10);
|
|
1308
|
+
if (!Number.isFinite(parsed)) {
|
|
1309
|
+
throw new Error(`${name} must be a number.`);
|
|
1310
|
+
}
|
|
1311
|
+
return parsed;
|
|
1362
1312
|
}
|
|
1363
1313
|
function readEnvironment(env = process.env) {
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1314
|
+
const result = {};
|
|
1315
|
+
if (env.SCP_NEXT_HOST)
|
|
1316
|
+
result.host = env.SCP_NEXT_HOST;
|
|
1317
|
+
if (env.SCP_NEXT_PORT) {
|
|
1318
|
+
const port = parseInteger$1(env.SCP_NEXT_PORT, "SCP_NEXT_PORT");
|
|
1319
|
+
if (port !== undefined)
|
|
1320
|
+
result.port = port;
|
|
1321
|
+
}
|
|
1322
|
+
if (env.SCP_NEXT_USERNAME)
|
|
1323
|
+
result.username = env.SCP_NEXT_USERNAME;
|
|
1324
|
+
if (env.SCP_NEXT_PASSWORD)
|
|
1325
|
+
result.password = env.SCP_NEXT_PASSWORD;
|
|
1326
|
+
if (env.SCP_NEXT_PRIVATE_KEY)
|
|
1327
|
+
result.privateKey = env.SCP_NEXT_PRIVATE_KEY;
|
|
1328
|
+
if (env.SCP_NEXT_PRIVATE_KEY_FILE)
|
|
1329
|
+
result.privateKeyFile = env.SCP_NEXT_PRIVATE_KEY_FILE;
|
|
1330
|
+
if (env.SCP_NEXT_PASSPHRASE)
|
|
1331
|
+
result.passphrase = env.SCP_NEXT_PASSPHRASE;
|
|
1332
|
+
if (env.SCP_NEXT_TIMEOUT) {
|
|
1333
|
+
const timeout = parseInteger$1(env.SCP_NEXT_TIMEOUT, "SCP_NEXT_TIMEOUT");
|
|
1334
|
+
if (timeout !== undefined)
|
|
1335
|
+
result.timeout = timeout;
|
|
1336
|
+
}
|
|
1337
|
+
if (env.SCP_NEXT_PROFILE)
|
|
1338
|
+
result.profile = env.SCP_NEXT_PROFILE;
|
|
1339
|
+
return result;
|
|
1381
1340
|
}
|
|
1382
1341
|
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
agent: z.string().optional(),
|
|
1397
|
-
timeout: z.number().int().optional(),
|
|
1398
|
-
hostFingerprint: z.string().optional(),
|
|
1399
|
-
knownHostsFile: z.string().optional()
|
|
1342
|
+
const CONFIG_FILENAMES = ["scp-next.config.json", ".scp-nextrc", ".scp-nextrc.json"];
|
|
1343
|
+
const serverSchema = z.object({
|
|
1344
|
+
host: z.string().optional(),
|
|
1345
|
+
port: z.number().int().optional(),
|
|
1346
|
+
username: z.string().optional(),
|
|
1347
|
+
password: z.string().optional(),
|
|
1348
|
+
privateKey: z.string().optional(),
|
|
1349
|
+
privateKeyFile: z.string().optional(),
|
|
1350
|
+
passphrase: z.string().optional(),
|
|
1351
|
+
agent: z.string().optional(),
|
|
1352
|
+
timeout: z.number().int().optional(),
|
|
1353
|
+
hostFingerprint: z.string().optional(),
|
|
1354
|
+
knownHostsFile: z.string().optional()
|
|
1400
1355
|
});
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1356
|
+
const transferSchema = z.object({
|
|
1357
|
+
recursive: z.boolean().optional(),
|
|
1358
|
+
overwrite: z.boolean().optional(),
|
|
1359
|
+
createDirectories: z.boolean().optional(),
|
|
1360
|
+
dryRun: z.boolean().optional(),
|
|
1361
|
+
timeout: z.number().int().optional(),
|
|
1362
|
+
postUploadCommands: z
|
|
1363
|
+
.array(z.string().refine((command) => command.trim().length > 0))
|
|
1364
|
+
.optional()
|
|
1408
1365
|
});
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1366
|
+
const jobSchema = transferSchema.extend({
|
|
1367
|
+
operation: z.enum(["upload", "download"]),
|
|
1368
|
+
profile: z.string().optional(),
|
|
1369
|
+
source: z.string(),
|
|
1370
|
+
destination: z.string()
|
|
1414
1371
|
});
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1372
|
+
const configSchema = serverSchema
|
|
1373
|
+
.extend({
|
|
1374
|
+
defaultProfile: z.string().optional(),
|
|
1375
|
+
server: serverSchema.optional(),
|
|
1376
|
+
transfer: transferSchema.optional(),
|
|
1377
|
+
profiles: z.record(serverSchema).optional(),
|
|
1378
|
+
jobs: z.record(jobSchema).optional()
|
|
1379
|
+
})
|
|
1380
|
+
.passthrough();
|
|
1422
1381
|
async function fileExists(filePath) {
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1382
|
+
try {
|
|
1383
|
+
await access(filePath);
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1429
1389
|
}
|
|
1430
1390
|
async function findConfigFile(cwd = process.cwd()) {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1391
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
1392
|
+
const candidate = path.resolve(cwd, filename);
|
|
1393
|
+
if (await fileExists(candidate)) {
|
|
1394
|
+
return candidate;
|
|
1395
|
+
}
|
|
1435
1396
|
}
|
|
1436
|
-
|
|
1437
|
-
return void 0;
|
|
1397
|
+
return undefined;
|
|
1438
1398
|
}
|
|
1439
1399
|
async function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
}
|
|
1444
|
-
let raw;
|
|
1445
|
-
try {
|
|
1446
|
-
raw = await readFile3(configPath, "utf8");
|
|
1447
|
-
} catch (error) {
|
|
1448
|
-
throw new ConfigurationError(`Unable to read configuration file: ${configPath}`, {
|
|
1449
|
-
cause: error,
|
|
1450
|
-
context: { path: configPath }
|
|
1451
|
-
});
|
|
1452
|
-
}
|
|
1453
|
-
try {
|
|
1454
|
-
const parsed = JSON.parse(raw);
|
|
1455
|
-
const result = configSchema.safeParse(parsed);
|
|
1456
|
-
if (!result.success) {
|
|
1457
|
-
throw new ConfigurationError(`Invalid configuration file: ${result.error.message}`, {
|
|
1458
|
-
context: { path: configPath }
|
|
1459
|
-
});
|
|
1400
|
+
const configPath = explicitPath ? resolveLocalPath(explicitPath, cwd) : await findConfigFile(cwd);
|
|
1401
|
+
if (!configPath) {
|
|
1402
|
+
return { config: {}, directory: cwd };
|
|
1460
1403
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1404
|
+
let raw;
|
|
1405
|
+
try {
|
|
1406
|
+
raw = await readFile(configPath, "utf8");
|
|
1407
|
+
}
|
|
1408
|
+
catch (error) {
|
|
1409
|
+
throw new ConfigurationError(`Unable to read configuration file: ${configPath}`, {
|
|
1410
|
+
cause: error,
|
|
1411
|
+
context: { path: configPath }
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
try {
|
|
1415
|
+
const parsed = JSON.parse(raw);
|
|
1416
|
+
const result = configSchema.safeParse(parsed);
|
|
1417
|
+
if (!result.success) {
|
|
1418
|
+
throw new ConfigurationError(`Invalid configuration file: ${result.error.message}`, {
|
|
1419
|
+
context: { path: configPath }
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
return {
|
|
1423
|
+
config: result.data,
|
|
1424
|
+
path: configPath,
|
|
1425
|
+
directory: path.dirname(configPath)
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
catch (error) {
|
|
1429
|
+
if (error instanceof ConfigurationError) {
|
|
1430
|
+
throw error;
|
|
1431
|
+
}
|
|
1432
|
+
throw new ConfigurationError(`Invalid JSON in configuration file: ${configPath}`, {
|
|
1433
|
+
cause: error,
|
|
1434
|
+
context: { path: configPath }
|
|
1435
|
+
});
|
|
1469
1436
|
}
|
|
1470
|
-
throw new ConfigurationError(`Invalid JSON in configuration file: ${configPath}`, {
|
|
1471
|
-
cause: error,
|
|
1472
|
-
context: { path: configPath }
|
|
1473
|
-
});
|
|
1474
|
-
}
|
|
1475
1437
|
}
|
|
1476
1438
|
|
|
1477
|
-
// src/config/resolve-config.ts
|
|
1478
|
-
import path6 from "path";
|
|
1479
|
-
import { assignDefined } from "mazey";
|
|
1480
|
-
|
|
1481
|
-
// src/config/jobs.ts
|
|
1482
1439
|
function selectJob(config, jobName) {
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1440
|
+
const job = config.jobs?.[jobName];
|
|
1441
|
+
if (!job) {
|
|
1442
|
+
throw new ConfigurationError(`Unknown job: ${jobName}`, {
|
|
1443
|
+
context: { job: jobName }
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
return job;
|
|
1490
1447
|
}
|
|
1491
1448
|
|
|
1492
|
-
// src/config/profiles.ts
|
|
1493
1449
|
function selectProfile(config, profileName) {
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1450
|
+
if (!profileName) {
|
|
1451
|
+
return undefined;
|
|
1452
|
+
}
|
|
1453
|
+
const profile = config.profiles?.[profileName];
|
|
1454
|
+
if (!profile) {
|
|
1455
|
+
throw new ConfigurationError(`Unknown profile: ${profileName}`, {
|
|
1456
|
+
context: { profile: profileName }
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
return profile;
|
|
1504
1460
|
}
|
|
1505
1461
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
dryRun: false
|
|
1462
|
+
const DEFAULT_TRANSFER = {
|
|
1463
|
+
port: 22,
|
|
1464
|
+
recursive: false,
|
|
1465
|
+
overwrite: false,
|
|
1466
|
+
createDirectories: true,
|
|
1467
|
+
dryRun: false
|
|
1513
1468
|
};
|
|
1514
1469
|
function rootServer(config) {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1470
|
+
const result = {};
|
|
1471
|
+
assignDefined(result, config.server);
|
|
1472
|
+
assignDefined(result, {
|
|
1473
|
+
host: config.host,
|
|
1474
|
+
port: config.port,
|
|
1475
|
+
username: config.username,
|
|
1476
|
+
password: config.password,
|
|
1477
|
+
privateKey: config.privateKey,
|
|
1478
|
+
privateKeyFile: config.privateKeyFile,
|
|
1479
|
+
passphrase: config.passphrase,
|
|
1480
|
+
agent: config.agent,
|
|
1481
|
+
timeout: config.timeout,
|
|
1482
|
+
hostFingerprint: config.hostFingerprint,
|
|
1483
|
+
knownHostsFile: config.knownHostsFile
|
|
1484
|
+
});
|
|
1485
|
+
return result;
|
|
1531
1486
|
}
|
|
1532
1487
|
function jobTransfer(job) {
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1488
|
+
const result = {};
|
|
1489
|
+
if (!job) {
|
|
1490
|
+
return result;
|
|
1491
|
+
}
|
|
1492
|
+
return assignDefined(result, {
|
|
1493
|
+
recursive: job.recursive,
|
|
1494
|
+
overwrite: job.overwrite,
|
|
1495
|
+
createDirectories: job.createDirectories,
|
|
1496
|
+
dryRun: job.dryRun,
|
|
1497
|
+
timeout: job.timeout,
|
|
1498
|
+
postUploadCommands: readPostUploadCommands(job)
|
|
1499
|
+
});
|
|
1545
1500
|
}
|
|
1546
1501
|
function resolveConfigRelatedPaths(resolved, operation, configDirectory) {
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1502
|
+
const next = { ...resolved };
|
|
1503
|
+
if (next.privateKeyFile && !next.privateKeyFile.startsWith("~")) {
|
|
1504
|
+
next.privateKeyFile = path.isAbsolute(next.privateKeyFile)
|
|
1505
|
+
? next.privateKeyFile
|
|
1506
|
+
: resolveLocalPath(next.privateKeyFile, configDirectory);
|
|
1507
|
+
}
|
|
1508
|
+
if (next.knownHostsFile && !next.knownHostsFile.startsWith("~")) {
|
|
1509
|
+
next.knownHostsFile = path.isAbsolute(next.knownHostsFile)
|
|
1510
|
+
? next.knownHostsFile
|
|
1511
|
+
: resolveLocalPath(next.knownHostsFile, configDirectory);
|
|
1512
|
+
}
|
|
1513
|
+
if (operation === "upload" && !next.source.startsWith("~")) {
|
|
1514
|
+
next.source = path.isAbsolute(next.source)
|
|
1515
|
+
? next.source
|
|
1516
|
+
: resolveLocalPath(next.source, configDirectory);
|
|
1517
|
+
}
|
|
1518
|
+
if (operation === "download" && !next.destination.startsWith("~")) {
|
|
1519
|
+
next.destination = path.isAbsolute(next.destination)
|
|
1520
|
+
? next.destination
|
|
1521
|
+
: resolveLocalPath(next.destination, configDirectory);
|
|
1522
|
+
}
|
|
1523
|
+
return next;
|
|
1561
1524
|
}
|
|
1562
1525
|
function resolveTransferConfig(input) {
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
resolved
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
pathResolved
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
if (
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1526
|
+
const config = input.config ?? {};
|
|
1527
|
+
const env = input.env ?? {};
|
|
1528
|
+
const cli = input.cli ?? {};
|
|
1529
|
+
const configDirectory = input.configDirectory ?? process.cwd();
|
|
1530
|
+
const cwd = input.cwd ?? process.cwd();
|
|
1531
|
+
const job = input.jobName ? selectJob(config, input.jobName) : undefined;
|
|
1532
|
+
const operation = input.operation ?? job?.operation;
|
|
1533
|
+
if (!operation) {
|
|
1534
|
+
throw new Error("Transfer operation is required.");
|
|
1535
|
+
}
|
|
1536
|
+
const selectedProfileName = cli.profile ?? env.profile ?? job?.profile ?? config.defaultProfile;
|
|
1537
|
+
const profile = selectProfile(config, selectedProfileName);
|
|
1538
|
+
const resolved = {
|
|
1539
|
+
...DEFAULT_TRANSFER,
|
|
1540
|
+
operation,
|
|
1541
|
+
source: job?.source ?? "",
|
|
1542
|
+
destination: job?.destination ?? ""
|
|
1543
|
+
};
|
|
1544
|
+
assignDefined(resolved, jobTransfer(job));
|
|
1545
|
+
assignDefined(resolved, config.transfer);
|
|
1546
|
+
assignDefined(resolved, rootServer(config));
|
|
1547
|
+
assignDefined(resolved, profile);
|
|
1548
|
+
assignDefined(resolved, {
|
|
1549
|
+
host: env.host,
|
|
1550
|
+
port: env.port,
|
|
1551
|
+
username: env.username,
|
|
1552
|
+
password: env.password,
|
|
1553
|
+
privateKey: env.privateKey,
|
|
1554
|
+
privateKeyFile: env.privateKeyFile,
|
|
1555
|
+
passphrase: env.passphrase,
|
|
1556
|
+
timeout: env.timeout
|
|
1557
|
+
});
|
|
1558
|
+
if (input.source !== undefined)
|
|
1559
|
+
resolved.source = input.source;
|
|
1560
|
+
if (input.destination !== undefined)
|
|
1561
|
+
resolved.destination = input.destination;
|
|
1562
|
+
assignDefined(resolved, cli);
|
|
1563
|
+
if (selectedProfileName) {
|
|
1564
|
+
resolved.profile = selectedProfileName;
|
|
1565
|
+
}
|
|
1566
|
+
const pathResolved = resolveConfigRelatedPaths(resolved, operation, configDirectory);
|
|
1567
|
+
getPostUploadCommands(pathResolved);
|
|
1568
|
+
if (input.source && operation === "upload") {
|
|
1569
|
+
pathResolved.source = resolveLocalPath(input.source, cwd);
|
|
1570
|
+
}
|
|
1571
|
+
if (operation === "upload") {
|
|
1572
|
+
pathResolved.destination = restoreMsysConvertedRemotePath(pathResolved.destination);
|
|
1573
|
+
}
|
|
1574
|
+
if (operation === "download") {
|
|
1575
|
+
pathResolved.source = restoreMsysConvertedRemotePath(pathResolved.source);
|
|
1576
|
+
if (getPostUploadCommands(job)?.length ||
|
|
1577
|
+
getPostUploadCommands(cli)?.length) {
|
|
1578
|
+
throw new ValidationError("Post-upload commands are not supported for downloads.");
|
|
1579
|
+
}
|
|
1580
|
+
clearPostUploadCommands(pathResolved);
|
|
1581
|
+
}
|
|
1582
|
+
if (input.destination && operation === "download") {
|
|
1583
|
+
pathResolved.destination = resolveLocalPath(input.destination, cwd);
|
|
1584
|
+
}
|
|
1585
|
+
return pathResolved;
|
|
1620
1586
|
}
|
|
1621
1587
|
|
|
1622
|
-
// src/cli/output.ts
|
|
1623
|
-
import { formatByteSize } from "mazey";
|
|
1624
1588
|
function writeDryRun(output, config) {
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
`);
|
|
1631
|
-
|
|
1632
|
-
`);
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
output.stdout.write(
|
|
1641
|
-
`${index + 1}. ${redactKnownSensitiveValues(command, config)}
|
|
1642
|
-
`
|
|
1643
|
-
);
|
|
1644
|
-
});
|
|
1645
|
-
}
|
|
1589
|
+
const postUploadCommands = getPostUploadCommands(config) ?? [];
|
|
1590
|
+
const destination = config.operation === "upload"
|
|
1591
|
+
? `${config.username ?? "user"}@${config.host ?? "host"}:${config.destination}`
|
|
1592
|
+
: config.destination;
|
|
1593
|
+
output.stdout.write(`Dry run: ${config.operation}\n`);
|
|
1594
|
+
output.stdout.write(`Source: ${config.source}\n`);
|
|
1595
|
+
output.stdout.write(`Destination: ${destination}\n`);
|
|
1596
|
+
output.stdout.write(`Recursive: ${config.recursive ? "yes" : "no"}\n`);
|
|
1597
|
+
output.stdout.write(`Overwrite: ${config.overwrite ? "yes" : "no"}\n`);
|
|
1598
|
+
if (config.operation === "upload" && postUploadCommands.length > 0) {
|
|
1599
|
+
output.stdout.write("\nPost-upload commands:\n");
|
|
1600
|
+
postUploadCommands.forEach((command, index) => {
|
|
1601
|
+
output.stdout.write(`${index + 1}. ${redactKnownSensitiveValues(command, config)}\n`);
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1646
1604
|
}
|
|
1647
1605
|
function writeCommandResults(output, results, config) {
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (!result.stderr.endsWith("\n")) output.stderr.write("\n");
|
|
1662
|
-
}
|
|
1663
|
-
});
|
|
1606
|
+
results.forEach((result, index) => {
|
|
1607
|
+
output.stdout.write(`Remote command ${index + 1} completed successfully.\n`);
|
|
1608
|
+
if (result.stdout) {
|
|
1609
|
+
output.stdout.write(redactKnownSensitiveValues(result.stdout, config));
|
|
1610
|
+
if (!result.stdout.endsWith("\n"))
|
|
1611
|
+
output.stdout.write("\n");
|
|
1612
|
+
}
|
|
1613
|
+
if (result.stderr) {
|
|
1614
|
+
output.stderr.write(redactKnownSensitiveValues(result.stderr, config));
|
|
1615
|
+
if (!result.stderr.endsWith("\n"))
|
|
1616
|
+
output.stderr.write("\n");
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1664
1619
|
}
|
|
1665
1620
|
function createProgressReporter(output, quiet) {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1621
|
+
if (quiet || !output.stderr.isTTY) {
|
|
1622
|
+
return undefined;
|
|
1623
|
+
}
|
|
1624
|
+
const minimumUpdateIntervalMs = 500;
|
|
1625
|
+
let lastLineLength = 0;
|
|
1626
|
+
let lastPercentage;
|
|
1627
|
+
let lastWriteAt = 0;
|
|
1628
|
+
let completed = false;
|
|
1629
|
+
return (progress) => {
|
|
1630
|
+
if (completed) {
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const now = Date.now();
|
|
1634
|
+
const totalBytes = progress.totalBytes;
|
|
1635
|
+
const isComplete = progress.percentage === 100 ||
|
|
1636
|
+
(totalBytes !== undefined && totalBytes > 0 && progress.transferredBytes >= totalBytes);
|
|
1637
|
+
const percentageChanged = progress.percentage !== undefined && progress.percentage !== lastPercentage;
|
|
1638
|
+
if (!isComplete &&
|
|
1639
|
+
!percentageChanged &&
|
|
1640
|
+
lastWriteAt !== 0 &&
|
|
1641
|
+
now - lastWriteAt < minimumUpdateIntervalMs) {
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const total = progress.totalBytes ? formatByteSize(progress.totalBytes) : "?";
|
|
1645
|
+
const transferred = formatByteSize(progress.transferredBytes);
|
|
1646
|
+
const percentage = progress.percentage === undefined ? "" : ` (${progress.percentage}%)`;
|
|
1647
|
+
const currentFile = progress.currentFile ? `: ${progress.currentFile}` : "";
|
|
1648
|
+
const line = `${progress.operation === "upload" ? "Uploading" : "Downloading"}${currentFile} ${transferred} / ${total}${percentage}`;
|
|
1649
|
+
const padding = " ".repeat(Math.max(0, lastLineLength - line.length));
|
|
1650
|
+
output.stderr.write(`\r${line}${padding}`);
|
|
1651
|
+
lastLineLength = line.length;
|
|
1652
|
+
lastPercentage = progress.percentage;
|
|
1653
|
+
lastWriteAt = now;
|
|
1654
|
+
if (isComplete) {
|
|
1655
|
+
output.stderr.write("\n");
|
|
1656
|
+
completed = true;
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1700
1659
|
}
|
|
1701
1660
|
function verbosePlan(output, config) {
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
`${JSON.stringify(
|
|
1705
|
-
redactSensitiveValues({
|
|
1661
|
+
const postUploadCommands = getPostUploadCommands(config);
|
|
1662
|
+
output.stderr.write(`${JSON.stringify(redactSensitiveValues({
|
|
1706
1663
|
operation: config.operation,
|
|
1707
1664
|
host: config.host,
|
|
1708
1665
|
port: config.port,
|
|
@@ -1713,263 +1670,259 @@ function verbosePlan(output, config) {
|
|
|
1713
1670
|
overwrite: config.overwrite,
|
|
1714
1671
|
createDirectories: config.createDirectories,
|
|
1715
1672
|
dryRun: config.dryRun,
|
|
1716
|
-
postUploadCommands: postUploadCommands?.map(
|
|
1717
|
-
|
|
1718
|
-
)
|
|
1719
|
-
}),
|
|
1720
|
-
null,
|
|
1721
|
-
2
|
|
1722
|
-
)}
|
|
1723
|
-
`
|
|
1724
|
-
);
|
|
1673
|
+
postUploadCommands: postUploadCommands?.map((_command, index) => `command-${index + 1}`)
|
|
1674
|
+
}), null, 2)}\n`);
|
|
1725
1675
|
}
|
|
1726
1676
|
|
|
1727
|
-
// src/cli/commands/shared.ts
|
|
1728
1677
|
function addTransferOptions(command, options = {}) {
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
)
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
return command;
|
|
1758
|
-
}
|
|
1759
|
-
function parseInteger2(value) {
|
|
1760
|
-
const parsed = Number.parseInt(value, 10);
|
|
1761
|
-
if (!Number.isFinite(parsed)) {
|
|
1762
|
-
throw new Error("Expected a number.");
|
|
1763
|
-
}
|
|
1764
|
-
return parsed;
|
|
1678
|
+
command.option("--host <host>", "SSH server host");
|
|
1679
|
+
command.addOption(new Option("--port <port>", "SSH server port").argParser(parseInteger));
|
|
1680
|
+
command.option("--username <username>", "SSH username");
|
|
1681
|
+
command.option("--password <password>", "SSH password (prefer environment variables or key files)");
|
|
1682
|
+
command.option("--private-key <privateKey>", "Private-key content");
|
|
1683
|
+
command.option("--private-key-file <privateKeyFile>", "Private-key file path");
|
|
1684
|
+
command.option("--passphrase <passphrase>", "Private-key passphrase");
|
|
1685
|
+
command.option("--config <path>", "Configuration file path");
|
|
1686
|
+
command.option("--profile <name>", "Named server profile");
|
|
1687
|
+
command.option("--recursive", "Transfer directories recursively");
|
|
1688
|
+
command.option("--overwrite", "Overwrite existing destination files");
|
|
1689
|
+
command.option("--create-directories", "Create missing destination directories");
|
|
1690
|
+
command.option("--no-create-directories", "Do not create missing destination directories");
|
|
1691
|
+
command.option("--dry-run", "Resolve and validate without connecting or transferring");
|
|
1692
|
+
command.addOption(new Option("--timeout <milliseconds>", "SSH connection ready timeout").argParser(parseInteger));
|
|
1693
|
+
command.option("--verbose", "Print verbose diagnostic output");
|
|
1694
|
+
command.option("--quiet", "Disable progress and non-error output");
|
|
1695
|
+
if (options.postUploadCommand) {
|
|
1696
|
+
command.option("--post-upload-command <command>", "Run a remote command after a successful upload (repeatable)", collectValues);
|
|
1697
|
+
}
|
|
1698
|
+
return command;
|
|
1699
|
+
}
|
|
1700
|
+
function parseInteger(value) {
|
|
1701
|
+
const parsed = Number.parseInt(value, 10);
|
|
1702
|
+
if (!Number.isFinite(parsed)) {
|
|
1703
|
+
throw new Error("Expected a number.");
|
|
1704
|
+
}
|
|
1705
|
+
return parsed;
|
|
1765
1706
|
}
|
|
1766
1707
|
function collectValues(value, previous) {
|
|
1767
|
-
|
|
1708
|
+
return [...(previous ?? []), value];
|
|
1768
1709
|
}
|
|
1769
1710
|
function toCliOptions(options) {
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1711
|
+
return {
|
|
1712
|
+
host: options.host,
|
|
1713
|
+
port: options.port,
|
|
1714
|
+
username: options.username,
|
|
1715
|
+
password: options.password,
|
|
1716
|
+
privateKey: options.privateKey,
|
|
1717
|
+
privateKeyFile: options.privateKeyFile,
|
|
1718
|
+
passphrase: options.passphrase,
|
|
1719
|
+
profile: options.profile,
|
|
1720
|
+
recursive: options.recursive,
|
|
1721
|
+
overwrite: options.overwrite,
|
|
1722
|
+
createDirectories: options.createDirectories,
|
|
1723
|
+
dryRun: options.dryRun,
|
|
1724
|
+
timeout: options.timeout,
|
|
1725
|
+
postUploadCommands: options.postUploadCommand
|
|
1726
|
+
};
|
|
1786
1727
|
}
|
|
1787
1728
|
async function resolveForCli(input) {
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
);
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
cwd: input.cwd
|
|
1804
|
-
});
|
|
1729
|
+
if (input.options.postUploadCommand?.some((command) => !command.trim())) {
|
|
1730
|
+
throw new ValidationError("Each --post-upload-command value must be a non-empty command.");
|
|
1731
|
+
}
|
|
1732
|
+
const loaded = await loadConfig(input.options.config, input.cwd);
|
|
1733
|
+
return resolveTransferConfig({
|
|
1734
|
+
operation: input.operation,
|
|
1735
|
+
source: input.source,
|
|
1736
|
+
destination: input.destination,
|
|
1737
|
+
jobName: input.jobName,
|
|
1738
|
+
cli: toCliOptions(input.options),
|
|
1739
|
+
env: readEnvironment(),
|
|
1740
|
+
config: loaded.config,
|
|
1741
|
+
configDirectory: loaded.directory,
|
|
1742
|
+
cwd: input.cwd
|
|
1743
|
+
});
|
|
1805
1744
|
}
|
|
1806
1745
|
async function executeResolvedTransfer(config, handlers, output, commandOptions) {
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1746
|
+
if (commandOptions.verbose) {
|
|
1747
|
+
verbosePlan(output, config);
|
|
1748
|
+
}
|
|
1749
|
+
if (config.dryRun) {
|
|
1750
|
+
if (!commandOptions.quiet) {
|
|
1751
|
+
writeDryRun(output, config);
|
|
1752
|
+
}
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
const onProgress = createProgressReporter(output, commandOptions.quiet);
|
|
1756
|
+
if (config.operation === "upload") {
|
|
1757
|
+
const uploadOptions = {
|
|
1758
|
+
...config,
|
|
1759
|
+
localPath: config.source,
|
|
1760
|
+
remotePath: config.destination
|
|
1761
|
+
};
|
|
1762
|
+
if (onProgress)
|
|
1763
|
+
uploadOptions.onProgress = onProgress;
|
|
1764
|
+
const results = await handlers.upload(uploadOptions);
|
|
1765
|
+
if (!commandOptions.quiet) {
|
|
1766
|
+
writeCommandResults(output, results, config);
|
|
1767
|
+
}
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const downloadOptions = {
|
|
1771
|
+
...config,
|
|
1772
|
+
remotePath: config.source,
|
|
1773
|
+
localPath: config.destination
|
|
1822
1774
|
};
|
|
1823
|
-
if (onProgress)
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
writeCommandResults(output, results, config);
|
|
1827
|
-
}
|
|
1828
|
-
return;
|
|
1829
|
-
}
|
|
1830
|
-
const downloadOptions = {
|
|
1831
|
-
...config,
|
|
1832
|
-
remotePath: config.source,
|
|
1833
|
-
localPath: config.destination
|
|
1834
|
-
};
|
|
1835
|
-
if (onProgress) downloadOptions.onProgress = onProgress;
|
|
1836
|
-
await handlers.download(downloadOptions);
|
|
1775
|
+
if (onProgress)
|
|
1776
|
+
downloadOptions.onProgress = onProgress;
|
|
1777
|
+
await handlers.download(downloadOptions);
|
|
1837
1778
|
}
|
|
1838
1779
|
|
|
1839
|
-
// src/cli/commands/download.ts
|
|
1840
1780
|
function createDownloadCommand(handlers, output, cwd) {
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1781
|
+
const command = new Command("download")
|
|
1782
|
+
.description("Download a remote source path to a local destination path")
|
|
1783
|
+
.usage("<source> <destination> [options]")
|
|
1784
|
+
.argument("[source]", "Remote file or directory path")
|
|
1785
|
+
.argument("[destination]", "Local file or directory")
|
|
1786
|
+
.action(async (source, destination, options) => {
|
|
1787
|
+
assertSourceDestination("download", source, destination);
|
|
1788
|
+
const resolved = await resolveForCli({
|
|
1789
|
+
operation: "download",
|
|
1790
|
+
source,
|
|
1791
|
+
destination,
|
|
1792
|
+
options,
|
|
1793
|
+
cwd
|
|
1794
|
+
});
|
|
1795
|
+
await executeResolvedTransfer(resolved, handlers, output, options);
|
|
1849
1796
|
});
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
addTransferOptions(command);
|
|
1853
|
-
return command;
|
|
1797
|
+
addTransferOptions(command);
|
|
1798
|
+
return command;
|
|
1854
1799
|
}
|
|
1855
1800
|
|
|
1856
|
-
// src/cli/commands/run.ts
|
|
1857
|
-
import { Command as Command2 } from "commander";
|
|
1858
1801
|
function createRunCommand(handlers, output, cwd) {
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
options
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1802
|
+
const command = new Command("run")
|
|
1803
|
+
.description("Run a configured transfer job")
|
|
1804
|
+
.usage("<job> [source] [destination] [options]")
|
|
1805
|
+
.argument("<job>", "Configured job name")
|
|
1806
|
+
.argument("[source]", "Optional source override")
|
|
1807
|
+
.argument("[destination]", "Optional destination override")
|
|
1808
|
+
.action(async (jobName, source, destination, options) => {
|
|
1809
|
+
const resolved = await resolveForCli({
|
|
1810
|
+
source,
|
|
1811
|
+
destination,
|
|
1812
|
+
jobName,
|
|
1813
|
+
options,
|
|
1814
|
+
cwd
|
|
1815
|
+
});
|
|
1816
|
+
await executeResolvedTransfer(resolved, handlers, output, options);
|
|
1817
|
+
});
|
|
1818
|
+
addTransferOptions(command, { postUploadCommand: true });
|
|
1819
|
+
return command;
|
|
1873
1820
|
}
|
|
1874
1821
|
|
|
1875
|
-
// src/cli/commands/upload.ts
|
|
1876
|
-
import { Command as Command3 } from "commander";
|
|
1877
1822
|
function createUploadCommand(handlers, output, cwd) {
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1823
|
+
const command = new Command("upload")
|
|
1824
|
+
.description("Upload a local source path to a remote destination path")
|
|
1825
|
+
.usage("<source> <destination> [options]")
|
|
1826
|
+
.argument("[source]", "Local file or directory")
|
|
1827
|
+
.argument("[destination]", "Remote file or directory path")
|
|
1828
|
+
.action(async (source, destination, options) => {
|
|
1829
|
+
assertSourceDestination("upload", source, destination);
|
|
1830
|
+
const resolved = await resolveForCli({
|
|
1831
|
+
operation: "upload",
|
|
1832
|
+
source,
|
|
1833
|
+
destination,
|
|
1834
|
+
options,
|
|
1835
|
+
cwd
|
|
1836
|
+
});
|
|
1837
|
+
await executeResolvedTransfer(resolved, handlers, output, options);
|
|
1886
1838
|
});
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
addTransferOptions(command, { postUploadCommand: true });
|
|
1890
|
-
return command;
|
|
1839
|
+
addTransferOptions(command, { postUploadCommand: true });
|
|
1840
|
+
return command;
|
|
1891
1841
|
}
|
|
1892
1842
|
|
|
1893
|
-
// src/cli/index.ts
|
|
1894
1843
|
function readPackageVersion(currentModuleUrl = import.meta.url) {
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1844
|
+
const currentFile = fileURLToPath(currentModuleUrl);
|
|
1845
|
+
const packageJsonPath = resolve(dirname(currentFile), "..", "..", "package.json");
|
|
1846
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
1847
|
+
return packageJson.version ?? "0.0.0";
|
|
1899
1848
|
}
|
|
1900
1849
|
function isCliEntrypoint(currentModuleUrl = import.meta.url, argv = process.argv) {
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1850
|
+
const executedFile = argv[1];
|
|
1851
|
+
if (!executedFile) {
|
|
1852
|
+
return false;
|
|
1853
|
+
}
|
|
1854
|
+
try {
|
|
1855
|
+
return realpathSync(fileURLToPath(currentModuleUrl)) === realpathSync(executedFile);
|
|
1856
|
+
}
|
|
1857
|
+
catch {
|
|
1858
|
+
return false;
|
|
1859
|
+
}
|
|
1910
1860
|
}
|
|
1911
1861
|
async function runCli(options = {}) {
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1862
|
+
const output = options.output ?? { stdout: process.stdout, stderr: process.stderr };
|
|
1863
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1864
|
+
const handlers = options.handlers ?? { upload, download };
|
|
1865
|
+
const program = new Command();
|
|
1866
|
+
program
|
|
1867
|
+
.name("scp-next")
|
|
1868
|
+
.description("Secure file transfers over SSH using SFTP")
|
|
1869
|
+
.version(readPackageVersion())
|
|
1870
|
+
.configureOutput({
|
|
1871
|
+
writeOut: (message) => output.stdout.write(message),
|
|
1872
|
+
writeErr: (message) => output.stderr.write(message)
|
|
1873
|
+
})
|
|
1874
|
+
.configureHelp({
|
|
1875
|
+
subcommandTerm: (command) => `${command.name()} ${command.usage()}`
|
|
1876
|
+
})
|
|
1877
|
+
.showHelpAfterError()
|
|
1878
|
+
.exitOverride();
|
|
1879
|
+
program.addCommand(createUploadCommand(handlers, output, cwd));
|
|
1880
|
+
program.addCommand(createDownloadCommand(handlers, output, cwd));
|
|
1881
|
+
program.addCommand(createRunCommand(handlers, output, cwd));
|
|
1882
|
+
try {
|
|
1883
|
+
await program.parseAsync(options.argv ?? process.argv, { from: "node" });
|
|
1931
1884
|
return 0;
|
|
1932
|
-
}
|
|
1933
|
-
output.stderr.write(`${formatErrorMessage(error.message)}
|
|
1934
|
-
`);
|
|
1935
|
-
return error.exitCode || 1;
|
|
1936
|
-
}
|
|
1937
|
-
const verbose = (options.argv ?? process.argv).includes("--verbose");
|
|
1938
|
-
if (error instanceof RemoteCommandError) {
|
|
1939
|
-
if (error.stdout) {
|
|
1940
|
-
output.stdout.write(redactKnownSensitiveValues(error.stdout, {}));
|
|
1941
|
-
if (!error.stdout.endsWith("\n")) output.stdout.write("\n");
|
|
1942
|
-
}
|
|
1943
|
-
if (error.stderr) {
|
|
1944
|
-
output.stderr.write(redactKnownSensitiveValues(error.stderr, {}));
|
|
1945
|
-
if (!error.stderr.endsWith("\n")) output.stderr.write("\n");
|
|
1946
|
-
}
|
|
1947
1885
|
}
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
`);
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1886
|
+
catch (error) {
|
|
1887
|
+
if (error instanceof CommanderError) {
|
|
1888
|
+
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
1889
|
+
return 0;
|
|
1890
|
+
}
|
|
1891
|
+
output.stderr.write(`${formatErrorMessage(error.message)}\n`);
|
|
1892
|
+
return error.exitCode || 1;
|
|
1893
|
+
}
|
|
1894
|
+
const verbose = (options.argv ?? process.argv).includes("--verbose");
|
|
1895
|
+
if (error instanceof RemoteCommandError) {
|
|
1896
|
+
if (error.stdout) {
|
|
1897
|
+
output.stdout.write(redactKnownSensitiveValues(error.stdout, {}));
|
|
1898
|
+
if (!error.stdout.endsWith("\n"))
|
|
1899
|
+
output.stdout.write("\n");
|
|
1900
|
+
}
|
|
1901
|
+
if (error.stderr) {
|
|
1902
|
+
output.stderr.write(redactKnownSensitiveValues(error.stderr, {}));
|
|
1903
|
+
if (!error.stderr.endsWith("\n"))
|
|
1904
|
+
output.stderr.write("\n");
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
if (error instanceof ScpNextError) {
|
|
1908
|
+
output.stderr.write(`Error: ${formatErrorMessage(error.message)}\n`);
|
|
1909
|
+
if (verbose && error.cause instanceof Error) {
|
|
1910
|
+
output.stderr.write(`${formatErrorMessage(error.cause.stack ?? error.cause.message)}\n`);
|
|
1911
|
+
}
|
|
1912
|
+
return 1;
|
|
1913
|
+
}
|
|
1914
|
+
output.stderr.write(`Error: ${formatErrorMessage(error)}\n`);
|
|
1915
|
+
if (verbose && error instanceof Error && error.stack) {
|
|
1916
|
+
output.stderr.write(`${formatErrorMessage(error.stack)}\n`);
|
|
1917
|
+
}
|
|
1918
|
+
return 1;
|
|
1919
|
+
}
|
|
1965
1920
|
}
|
|
1966
1921
|
if (isCliEntrypoint()) {
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1922
|
+
void runCli().then((exitCode) => {
|
|
1923
|
+
process.exitCode = exitCode;
|
|
1924
|
+
});
|
|
1970
1925
|
}
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
};
|
|
1975
|
-
//# sourceMappingURL=index.js.map
|
|
1926
|
+
|
|
1927
|
+
export { isCliEntrypoint, runCli };
|
|
1928
|
+
//# sourceMappingURL=index.js.map
|