scp-next 1.0.20 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -1
- package/README.md +81 -27
- package/dist/cli/index.js +1732 -1371
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +1204 -928
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -5
- package/dist/index.d.ts +50 -5
- package/dist/index.js +1193 -883
- package/dist/index.js.map +1 -1
- package/guides/ARCHITECTURE.md +88 -0
- package/guides/mazey-candidates.md +364 -0
- package/package.json +44 -12
- package/docs/ARCHITECTURE.md +0 -55
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.14.md +0 -0
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.19-en.md +0 -0
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.19-zh.md +0 -0
- /package/{docs → guides}/release-notes/introducing-scp-next-v1.0.2.md +0 -0
package/dist/cli/index.js
CHANGED
|
@@ -1,1323 +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 { readFile as readFile2 } from "fs/promises";
|
|
15
|
-
import SftpClient from "ssh2-sftp-client";
|
|
16
|
-
|
|
17
|
-
// src/security/redact.ts
|
|
18
|
-
var SENSITIVE_KEYS = [
|
|
19
|
-
"password",
|
|
20
|
-
"passphrase",
|
|
21
|
-
"privatekey",
|
|
22
|
-
"privatekeyfile",
|
|
23
|
-
"token",
|
|
24
|
-
"authorization",
|
|
25
|
-
"credential",
|
|
26
|
-
"secret"
|
|
14
|
+
const SENSITIVE_KEYS = [
|
|
15
|
+
"password",
|
|
16
|
+
"passphrase",
|
|
17
|
+
"privatekey",
|
|
18
|
+
"privatekeyfile",
|
|
19
|
+
"token",
|
|
20
|
+
"authorization",
|
|
21
|
+
"credential",
|
|
22
|
+
"secret"
|
|
27
23
|
];
|
|
28
|
-
|
|
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;
|
|
29
27
|
function isSensitiveKey(key) {
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
29
|
+
return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
|
|
32
30
|
}
|
|
33
31
|
function redactSensitiveValues(value) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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;
|
|
49
47
|
}
|
|
50
48
|
function formatErrorMessage(error) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
if (error instanceof Error) {
|
|
50
|
+
return redactKnownSensitiveValues(error.message, {});
|
|
51
|
+
}
|
|
52
|
+
return redactKnownSensitiveValues(String(error), {});
|
|
53
|
+
}
|
|
54
|
+
function redactKnownSensitiveValues(text, source) {
|
|
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]");
|
|
60
|
+
}
|
|
61
|
+
function collectSensitiveValues(value, key = "") {
|
|
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));
|
|
55
75
|
}
|
|
56
76
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
};
|
|
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
|
+
}
|
|
103
139
|
function toScpNextError(error) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 });
|
|
119
160
|
}
|
|
120
161
|
|
|
121
|
-
// src/security/host-verification.ts
|
|
122
|
-
import { createHash } from "crypto";
|
|
123
|
-
import { access as access2, readFile } from "fs/promises";
|
|
124
|
-
|
|
125
|
-
// src/paths/local-path.ts
|
|
126
|
-
import { constants } from "fs";
|
|
127
|
-
import { access, mkdir, stat } from "fs/promises";
|
|
128
|
-
import os from "os";
|
|
129
|
-
import path from "path";
|
|
130
162
|
function expandHome(inputPath) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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;
|
|
138
170
|
}
|
|
139
171
|
function resolveLocalPath(inputPath, baseDir = process.cwd()) {
|
|
140
|
-
|
|
141
|
-
|
|
172
|
+
const expanded = expandHome(inputPath);
|
|
173
|
+
return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(baseDir, expanded);
|
|
142
174
|
}
|
|
143
175
|
async function assertLocalPathExists(inputPath) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
}
|
|
152
185
|
}
|
|
153
186
|
async function assertDownloadDestination(inputPath, createDirectories = false) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
}
|
|
168
202
|
}
|
|
169
203
|
async function getLocalPathKind(inputPath) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
});
|
|
180
214
|
}
|
|
181
215
|
|
|
182
|
-
// src/security/host-verification.ts
|
|
183
216
|
function normalizeFingerprint(fingerprint) {
|
|
184
|
-
|
|
217
|
+
return fingerprint
|
|
218
|
+
.trim()
|
|
219
|
+
.replace(/^SHA256:/i, "")
|
|
220
|
+
.replace(/^sha256:/i, "")
|
|
221
|
+
.replace(/=+$/g, "");
|
|
185
222
|
}
|
|
186
223
|
function isHexFingerprint(fingerprint) {
|
|
187
|
-
|
|
224
|
+
return /^[0-9a-f]+$/i.test(fingerprint);
|
|
188
225
|
}
|
|
189
226
|
function fingerprintCandidates(fingerprint) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
227
|
+
const normalized = normalizeFingerprint(fingerprint);
|
|
228
|
+
const candidates = new Set([normalized]);
|
|
229
|
+
if (isHexFingerprint(normalized)) {
|
|
230
|
+
candidates.add(normalized.toLowerCase());
|
|
231
|
+
}
|
|
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
|
+
}
|
|
198
239
|
}
|
|
199
|
-
|
|
200
|
-
return [...candidates];
|
|
240
|
+
return [...candidates];
|
|
201
241
|
}
|
|
202
242
|
function isFingerprintMatch(actual, expected) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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());
|
|
209
251
|
}
|
|
210
252
|
function hostVerificationHint(host) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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");
|
|
219
261
|
}
|
|
220
262
|
async function resolveAllowedFingerprints(options) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
`SSH host verification failed before connecting: no matching host fingerprint was configured.${hostVerificationHint(
|
|
261
|
-
options.host
|
|
262
|
-
)}`
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
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;
|
|
266
302
|
}
|
|
267
303
|
function createHostVerifier(allowedFingerprints) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
304
|
+
return (hashedKey) => {
|
|
305
|
+
for (const expected of allowedFingerprints) {
|
|
306
|
+
if (isFingerprintMatch(hashedKey, expected)) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
};
|
|
276
312
|
}
|
|
277
313
|
|
|
278
|
-
|
|
279
|
-
function toRemoteFileInfo(info) {
|
|
280
|
-
return {
|
|
281
|
-
type: info.type,
|
|
282
|
-
name: info.name,
|
|
283
|
-
size: info.size
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
var Ssh2SftpTransport = class {
|
|
287
|
-
client;
|
|
288
|
-
constructor(name = "scp-next") {
|
|
289
|
-
this.client = new SftpClient(name, {
|
|
290
|
-
error: () => void 0,
|
|
291
|
-
end: () => void 0,
|
|
292
|
-
close: () => void 0
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
async connect(options) {
|
|
296
|
-
const allowedFingerprints = await resolveAllowedFingerprints(options);
|
|
297
|
-
const hostVerifier = createHostVerifier(allowedFingerprints);
|
|
314
|
+
async function createSshConnectOptions(options) {
|
|
298
315
|
const connectOptions = {};
|
|
299
|
-
if (options.host !==
|
|
300
|
-
|
|
301
|
-
if (options.
|
|
302
|
-
|
|
303
|
-
if (options.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const agent = options.agent ?? process.env.SSH_AUTH_SOCK;
|
|
310
|
-
if (agent !== void 0) {
|
|
311
|
-
connectOptions.agent = agent;
|
|
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;
|
|
312
326
|
}
|
|
313
|
-
if (options.
|
|
314
|
-
|
|
315
|
-
connectOptions.hostHash = "sha256";
|
|
316
|
-
connectOptions.hostVerifier = hostVerifier;
|
|
327
|
+
else if (options.privateKeyFile) {
|
|
328
|
+
connectOptions.privateKey = await readFile(expandHome(options.privateKeyFile), "utf8");
|
|
317
329
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (isLikelyHostVerificationFailure(error)) {
|
|
331
|
-
throw new HostVerificationError(
|
|
332
|
-
`SSH host verification failed during connection.${hostVerificationTroubleshooting(
|
|
333
|
-
options.host
|
|
334
|
-
)}`,
|
|
335
|
-
{ cause: error, context: connectionContext(options) }
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
throw new ConnectionError(`Unable to connect to SSH server: ${formatErrorMessage(error)}`, {
|
|
339
|
-
cause: error,
|
|
340
|
-
context: connectionContext(options)
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
async close() {
|
|
345
|
-
await this.client.end();
|
|
346
|
-
}
|
|
347
|
-
async exists(remotePath) {
|
|
348
|
-
return this.client.exists(remotePath);
|
|
349
|
-
}
|
|
350
|
-
async mkdir(remotePath, recursive = true) {
|
|
351
|
-
await this.client.mkdir(remotePath, recursive);
|
|
352
|
-
}
|
|
353
|
-
async list(remotePath) {
|
|
354
|
-
const files = await this.client.list(remotePath);
|
|
355
|
-
return files.map(toRemoteFileInfo);
|
|
356
|
-
}
|
|
357
|
-
async uploadFile(localPath, remotePath, onStep) {
|
|
358
|
-
try {
|
|
359
|
-
await this.client.fastPut(localPath, remotePath, this.transferOptions(onStep));
|
|
360
|
-
} catch (error) {
|
|
361
|
-
throw new TransferError(`Upload failed for ${localPath}: ${formatErrorMessage(error)}`, {
|
|
362
|
-
cause: error,
|
|
363
|
-
context: { localPath, remotePath }
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
async downloadFile(remotePath, localPath, onStep) {
|
|
368
|
-
try {
|
|
369
|
-
await this.client.fastGet(remotePath, localPath, this.transferOptions(onStep));
|
|
370
|
-
} catch (error) {
|
|
371
|
-
throw new TransferError(`Download failed for ${remotePath}: ${formatErrorMessage(error)}`, {
|
|
372
|
-
cause: error,
|
|
373
|
-
context: { remotePath, localPath }
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
transferOptions(onStep) {
|
|
378
|
-
if (!onStep) {
|
|
379
|
-
return void 0;
|
|
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;
|
|
380
342
|
}
|
|
343
|
+
return connectOptions;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function toRemoteFileInfo(info) {
|
|
381
347
|
return {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
348
|
+
type: info.type,
|
|
349
|
+
name: info.name,
|
|
350
|
+
size: info.size
|
|
385
351
|
};
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
}
|
|
388
427
|
function connectionContext(options) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
428
|
+
return {
|
|
429
|
+
host: options.host,
|
|
430
|
+
port: options.port,
|
|
431
|
+
username: options.username
|
|
432
|
+
};
|
|
394
433
|
}
|
|
395
434
|
function isLikelyHostVerificationFailure(error) {
|
|
396
|
-
|
|
397
|
-
|
|
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")));
|
|
398
442
|
}
|
|
399
443
|
function hostVerificationTroubleshooting(host) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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");
|
|
412
456
|
}
|
|
413
457
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
}
|
|
552
|
+
this.connected = false;
|
|
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();
|
|
579
|
+
try {
|
|
580
|
+
stream.close();
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// The connection may already be closed.
|
|
584
|
+
}
|
|
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
|
+
}));
|
|
595
|
+
return;
|
|
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.", {
|
|
604
|
+
exitCode: null,
|
|
605
|
+
stdout,
|
|
606
|
+
stderr
|
|
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);
|
|
644
|
+
}
|
|
417
645
|
|
|
418
|
-
// src/paths/remote-path.ts
|
|
419
|
-
import path2 from "path";
|
|
420
646
|
function assertRemotePath(inputPath, label = "remote path") {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
+
}
|
|
426
652
|
}
|
|
427
653
|
function normalizeRemotePath(inputPath) {
|
|
428
|
-
|
|
429
|
-
|
|
654
|
+
assertRemotePath(inputPath);
|
|
655
|
+
return path.posix.normalize(inputPath.replace(/\\/g, "/"));
|
|
430
656
|
}
|
|
431
657
|
function restoreMsysConvertedRemotePath(inputPath) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
);
|
|
436
|
-
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;
|
|
437
661
|
}
|
|
438
662
|
function remoteJoin(...parts) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
663
|
+
const joined = path.posix.join(...parts);
|
|
664
|
+
assertRemotePath(joined);
|
|
665
|
+
return joined;
|
|
442
666
|
}
|
|
443
667
|
function remoteDirname(inputPath) {
|
|
444
|
-
|
|
445
|
-
|
|
668
|
+
assertRemotePath(inputPath);
|
|
669
|
+
return path.posix.dirname(inputPath);
|
|
446
670
|
}
|
|
447
671
|
|
|
448
|
-
// src/client/walk.ts
|
|
449
672
|
async function walkLocalFiles(rootPath) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
+
}
|
|
476
701
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
return files;
|
|
702
|
+
await visit(resolvedRoot);
|
|
703
|
+
return files;
|
|
480
704
|
}
|
|
481
705
|
async function walkRemoteFiles(transport, rootPath) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
+
}
|
|
723
|
+
}
|
|
724
|
+
await visit(rootPath, "", rootEntries);
|
|
725
|
+
return files;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function isPostUploadCommands(value) {
|
|
729
|
+
return (Array.isArray(value) &&
|
|
730
|
+
value.every((command) => typeof command === "string" && command.trim().length > 0));
|
|
731
|
+
}
|
|
732
|
+
function getPostUploadCommands(source) {
|
|
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.");
|
|
497
739
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
740
|
+
return commands;
|
|
741
|
+
}
|
|
742
|
+
function readPostUploadCommands(source) {
|
|
743
|
+
if (typeof source !== "object" || source === null) {
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
746
|
+
return source
|
|
747
|
+
.postUploadCommands;
|
|
748
|
+
}
|
|
749
|
+
function clearPostUploadCommands(source) {
|
|
750
|
+
source.postUploadCommands =
|
|
751
|
+
undefined;
|
|
501
752
|
}
|
|
502
753
|
|
|
503
|
-
// src/config/validation.ts
|
|
504
|
-
import { access as access3 } from "fs/promises";
|
|
505
754
|
function validatePort(port) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
+
}
|
|
514
763
|
}
|
|
515
764
|
function validateTimeout(timeout) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
}
|
|
524
773
|
}
|
|
525
774
|
async function validateServerOptions(options) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
+
}
|
|
550
802
|
}
|
|
551
803
|
async function validateUploadOptions(options) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
+
}
|
|
564
817
|
}
|
|
565
818
|
async function validateDownloadOptions(options) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
+
}
|
|
578
834
|
}
|
|
579
835
|
|
|
580
|
-
// src/client/client.ts
|
|
581
836
|
function percentage(transferredBytes, totalBytes) {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
837
|
+
if (!totalBytes || totalBytes <= 0) {
|
|
838
|
+
return undefined;
|
|
839
|
+
}
|
|
840
|
+
return Math.min(100, Math.round((transferredBytes / totalBytes) * 100));
|
|
586
841
|
}
|
|
587
842
|
function mergeOptions(serverOptions, transferOptions) {
|
|
588
|
-
|
|
843
|
+
return { ...serverOptions, ...(transferOptions ?? {}) };
|
|
589
844
|
}
|
|
590
845
|
function shouldCreateDirectories(options) {
|
|
591
|
-
|
|
846
|
+
return options.createDirectories ?? true;
|
|
592
847
|
}
|
|
593
848
|
function hasTrailingPathSeparator(inputPath) {
|
|
594
|
-
|
|
849
|
+
return /[\\/]$/.test(inputPath);
|
|
595
850
|
}
|
|
596
851
|
function trimmedRemoteDirectory(remotePath) {
|
|
597
|
-
|
|
852
|
+
return remotePath.replace(/\/+$/g, "") || "/";
|
|
598
853
|
}
|
|
599
854
|
function remoteBasename(remotePath) {
|
|
600
|
-
|
|
855
|
+
return path.posix.basename(trimmedRemoteDirectory(remotePath));
|
|
601
856
|
}
|
|
602
857
|
async function isExistingLocalDirectory(localPath) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
858
|
+
try {
|
|
859
|
+
return (await stat(localPath)).isDirectory();
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
608
864
|
}
|
|
609
865
|
async function ensureLocalDirectory(localPath, createParents = false) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
});
|
|
618
|
-
} catch (error) {
|
|
619
|
-
if (error instanceof TransferError) {
|
|
620
|
-
throw error;
|
|
621
|
-
}
|
|
622
|
-
await mkdir2(localPath, { recursive: createParents });
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
var ScpNextClientImpl = class {
|
|
626
|
-
serverOptions;
|
|
627
|
-
transport;
|
|
628
|
-
connected = false;
|
|
629
|
-
constructor(serverOptions, dependencies = {}) {
|
|
630
|
-
this.serverOptions = serverOptions;
|
|
631
|
-
this.transport = dependencies.transport ?? new Ssh2SftpTransport();
|
|
632
|
-
}
|
|
633
|
-
async connect() {
|
|
634
|
-
await validateServerOptions(this.serverOptions);
|
|
635
|
-
await this.transport.connect(this.serverOptions);
|
|
636
|
-
this.connected = true;
|
|
637
|
-
}
|
|
638
|
-
async upload(localPath, remotePath, options = {}) {
|
|
639
|
-
const uploadOptions = {
|
|
640
|
-
...mergeOptions(this.serverOptions, options),
|
|
641
|
-
localPath,
|
|
642
|
-
remotePath
|
|
643
|
-
};
|
|
644
|
-
await validateUploadOptions(uploadOptions);
|
|
645
|
-
if (options.dryRun) {
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
648
|
-
await this.ensureConnected();
|
|
649
|
-
await this.uploadWithTransport(uploadOptions);
|
|
650
|
-
}
|
|
651
|
-
async download(remotePath, localPath, options = {}) {
|
|
652
|
-
const downloadOptions = {
|
|
653
|
-
...mergeOptions(this.serverOptions, options),
|
|
654
|
-
remotePath,
|
|
655
|
-
localPath
|
|
656
|
-
};
|
|
657
|
-
await validateDownloadOptions(downloadOptions);
|
|
658
|
-
if (options.dryRun) {
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
await this.ensureConnected();
|
|
662
|
-
await this.downloadWithTransport(downloadOptions);
|
|
663
|
-
}
|
|
664
|
-
async close() {
|
|
665
|
-
if (this.connected) {
|
|
666
|
-
await this.transport.close();
|
|
667
|
-
this.connected = false;
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
async ensureConnected() {
|
|
671
|
-
if (!this.connected) {
|
|
672
|
-
await this.connect();
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
async uploadWithTransport(options) {
|
|
676
|
-
const localKind = await getLocalPathKind(options.localPath);
|
|
677
|
-
const remotePath = normalizeRemotePath(options.remotePath);
|
|
678
|
-
if (localKind === "directory" && !options.recursive) {
|
|
679
|
-
throw new ValidationError("Uploading a directory requires recursive mode.");
|
|
680
|
-
}
|
|
681
|
-
if (localKind === "file") {
|
|
682
|
-
const destinationFile = await this.resolveRemoteDestinationForLocalFile(options, remotePath);
|
|
683
|
-
const totalBytes2 = void 0;
|
|
684
|
-
await this.transport.uploadFile(resolveLocalPath(options.localPath), destinationFile, (step) => {
|
|
685
|
-
options.onProgress?.({
|
|
686
|
-
operation: "upload",
|
|
687
|
-
source: options.localPath,
|
|
688
|
-
destination: options.remotePath,
|
|
689
|
-
transferredBytes: step.transferredBytes,
|
|
690
|
-
totalBytes: step.totalBytes || totalBytes2,
|
|
691
|
-
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
692
|
-
currentFile: options.localPath
|
|
693
|
-
});
|
|
694
|
-
});
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
const files = await walkLocalFiles(options.localPath);
|
|
698
|
-
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
699
|
-
let completedBytes = 0;
|
|
700
|
-
let completedFiles = 0;
|
|
701
|
-
const destinationRoot = await this.resolveRemoteDestinationForLocalDirectory(
|
|
702
|
-
options,
|
|
703
|
-
remotePath
|
|
704
|
-
);
|
|
705
|
-
for (const file of files) {
|
|
706
|
-
const destinationFile = remoteJoin(
|
|
707
|
-
destinationRoot,
|
|
708
|
-
file.relativePath.split(path4.sep).join("/")
|
|
709
|
-
);
|
|
710
|
-
await this.ensureRemoteParent(destinationFile, true);
|
|
711
|
-
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
712
|
-
await this.transport.uploadFile(file.absolutePath, destinationFile, (step) => {
|
|
713
|
-
const transferredBytes = completedBytes + step.transferredBytes;
|
|
714
|
-
options.onProgress?.({
|
|
715
|
-
operation: "upload",
|
|
716
|
-
source: options.localPath,
|
|
717
|
-
destination: options.remotePath,
|
|
718
|
-
transferredBytes,
|
|
719
|
-
totalBytes,
|
|
720
|
-
percentage: percentage(transferredBytes, totalBytes),
|
|
721
|
-
currentFile: file.relativePath,
|
|
722
|
-
completedFiles,
|
|
723
|
-
totalFiles: files.length
|
|
724
|
-
});
|
|
725
|
-
});
|
|
726
|
-
completedBytes += file.size;
|
|
727
|
-
completedFiles += 1;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
async resolveRemoteDestinationForLocalFile(options, remotePath) {
|
|
731
|
-
const remoteKind = await this.transport.exists(remotePath);
|
|
732
|
-
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
733
|
-
if (!isDirectoryLikeDestination) {
|
|
734
|
-
await this.ensureRemoteParent(remotePath, shouldCreateDirectories(options));
|
|
735
|
-
await this.assertRemoteOverwrite(remotePath, options.overwrite);
|
|
736
|
-
return remotePath;
|
|
737
|
-
}
|
|
738
|
-
if (remoteKind && remoteKind !== "d") {
|
|
739
|
-
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
740
|
-
context: { remotePath }
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
const remoteDirectory = remotePath.replace(/\/+$/g, "") || "/";
|
|
744
|
-
if (!remoteKind) {
|
|
745
|
-
if (!shouldCreateDirectories(options)) {
|
|
746
|
-
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
747
|
-
context: { remotePath }
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
await this.transport.mkdir(remoteDirectory, true);
|
|
751
|
-
}
|
|
752
|
-
const destinationFile = remoteJoin(
|
|
753
|
-
remoteDirectory,
|
|
754
|
-
path4.basename(resolveLocalPath(options.localPath))
|
|
755
|
-
);
|
|
756
|
-
await this.assertRemoteOverwrite(destinationFile, options.overwrite);
|
|
757
|
-
return destinationFile;
|
|
758
|
-
}
|
|
759
|
-
async resolveRemoteDestinationForLocalDirectory(options, remotePath) {
|
|
760
|
-
const remoteKind = await this.transport.exists(remotePath);
|
|
761
|
-
const isDirectoryLikeDestination = remoteKind === "d" || remotePath.endsWith("/");
|
|
762
|
-
const localDirectoryName = path4.basename(resolveLocalPath(options.localPath));
|
|
763
|
-
if (remoteKind && remoteKind !== "d") {
|
|
764
|
-
throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
|
|
765
|
-
context: { remotePath }
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
if (isDirectoryLikeDestination) {
|
|
769
|
-
const remoteDirectory = trimmedRemoteDirectory(remotePath);
|
|
770
|
-
if (!remoteKind) {
|
|
771
|
-
if (!shouldCreateDirectories(options)) {
|
|
772
|
-
throw new TransferError(`Remote destination directory does not exist: ${remoteDirectory}`, {
|
|
773
|
-
context: { remotePath }
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
await this.transport.mkdir(remoteDirectory, true);
|
|
777
|
-
}
|
|
778
|
-
const destinationRoot = remoteJoin(remoteDirectory, localDirectoryName);
|
|
779
|
-
await this.ensureRemoteDirectory(destinationRoot);
|
|
780
|
-
return destinationRoot;
|
|
781
|
-
}
|
|
782
|
-
if (!remoteKind) {
|
|
783
|
-
if (!shouldCreateDirectories(options)) {
|
|
784
|
-
throw new TransferError(`Remote destination directory does not exist: ${remotePath}`, {
|
|
785
|
-
context: { remotePath }
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
await this.transport.mkdir(remotePath, true);
|
|
789
|
-
}
|
|
790
|
-
return remotePath;
|
|
791
|
-
}
|
|
792
|
-
async downloadWithTransport(options) {
|
|
793
|
-
const remotePath = normalizeRemotePath(options.remotePath);
|
|
794
|
-
const remoteKind = await this.transport.exists(remotePath);
|
|
795
|
-
if (!remoteKind) {
|
|
796
|
-
throw new TransferError(`Remote path does not exist: ${options.remotePath}`, {
|
|
797
|
-
context: { remotePath: options.remotePath }
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
if (remoteKind === "d") {
|
|
801
|
-
if (!options.recursive) {
|
|
802
|
-
throw new ValidationError("Downloading a directory requires recursive mode.");
|
|
803
|
-
}
|
|
804
|
-
await this.downloadDirectory(options);
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
await this.downloadSingleFile(options, remotePath);
|
|
808
|
-
}
|
|
809
|
-
async downloadSingleFile(options, remotePath) {
|
|
810
|
-
const localPath = await this.resolveLocalDestinationForRemoteFile(options, remotePath);
|
|
811
|
-
await this.assertLocalOverwrite(localPath, options.overwrite);
|
|
812
|
-
if (shouldCreateDirectories(options)) {
|
|
813
|
-
await mkdir2(path4.dirname(localPath), { recursive: true });
|
|
814
|
-
}
|
|
815
|
-
await this.transport.downloadFile(remotePath, localPath, (step) => {
|
|
816
|
-
options.onProgress?.({
|
|
817
|
-
operation: "download",
|
|
818
|
-
source: options.remotePath,
|
|
819
|
-
destination: options.localPath,
|
|
820
|
-
transferredBytes: step.transferredBytes,
|
|
821
|
-
totalBytes: step.totalBytes,
|
|
822
|
-
percentage: percentage(step.transferredBytes, step.totalBytes),
|
|
823
|
-
currentFile: options.remotePath
|
|
824
|
-
});
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
async downloadDirectory(options) {
|
|
828
|
-
const remotePath = normalizeRemotePath(options.remotePath);
|
|
829
|
-
const localRoot = await this.resolveLocalDestinationForRemoteDirectory(options, remotePath);
|
|
830
|
-
const files = await walkRemoteFiles(this.transport, remotePath);
|
|
831
|
-
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
832
|
-
let completedBytes = 0;
|
|
833
|
-
let completedFiles = 0;
|
|
834
|
-
await ensureLocalDirectory(localRoot, shouldCreateDirectories(options));
|
|
835
|
-
for (const file of files) {
|
|
836
|
-
const localFile = path4.join(localRoot, ...file.relativePath.split("/"));
|
|
837
|
-
await this.assertLocalOverwrite(localFile, options.overwrite);
|
|
838
|
-
await mkdir2(path4.dirname(localFile), { recursive: true });
|
|
839
|
-
await this.transport.downloadFile(file.remotePath, localFile, (step) => {
|
|
840
|
-
const transferredBytes = completedBytes + step.transferredBytes;
|
|
841
|
-
options.onProgress?.({
|
|
842
|
-
operation: "download",
|
|
843
|
-
source: options.remotePath,
|
|
844
|
-
destination: options.localPath,
|
|
845
|
-
transferredBytes,
|
|
846
|
-
totalBytes,
|
|
847
|
-
percentage: percentage(transferredBytes, totalBytes),
|
|
848
|
-
currentFile: file.relativePath,
|
|
849
|
-
completedFiles,
|
|
850
|
-
totalFiles: files.length
|
|
851
|
-
});
|
|
852
|
-
});
|
|
853
|
-
completedBytes += file.size;
|
|
854
|
-
completedFiles += 1;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
async resolveLocalDestinationForRemoteFile(options, remotePath) {
|
|
858
|
-
const localDestination = resolveLocalPath(options.localPath);
|
|
859
|
-
const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
|
|
860
|
-
if (!isDirectoryLikeDestination) {
|
|
861
|
-
return localDestination;
|
|
862
|
-
}
|
|
863
|
-
if (!await isExistingLocalDirectory(localDestination)) {
|
|
864
|
-
if (!shouldCreateDirectories(options)) {
|
|
865
|
-
throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
|
|
866
|
-
context: { localPath: options.localPath }
|
|
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 }
|
|
867
873
|
});
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
874
|
+
}
|
|
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;
|
|
897
|
+
}
|
|
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);
|
|
928
|
+
}
|
|
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
|
+
}
|
|
988
|
+
}
|
|
989
|
+
async ensureConnected() {
|
|
990
|
+
if (!this.connected) {
|
|
991
|
+
await this.connect();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async ensureCommandExecutorConnected() {
|
|
995
|
+
if (!this.commandExecutorConnected) {
|
|
996
|
+
await validateServerOptions(this.serverOptions);
|
|
997
|
+
await this.commandExecutor.connect(this.serverOptions);
|
|
998
|
+
this.commandExecutorConnected = true;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
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
|
+
}
|
|
1049
|
+
}
|
|
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
|
+
});
|
|
883
1142
|
});
|
|
884
|
-
}
|
|
885
|
-
await mkdir2(localDestination, { recursive: true });
|
|
886
1143
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
+
}
|
|
893
1173
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
context: { remotePath }
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
if (!exists) {
|
|
913
|
-
await this.transport.mkdir(remotePath, true);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
async assertRemoteOverwrite(remotePath, overwrite = false) {
|
|
917
|
-
if (overwrite) {
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
const exists = await this.transport.exists(remotePath);
|
|
921
|
-
if (exists) {
|
|
922
|
-
throw new TransferError(
|
|
923
|
-
`Remote destination already exists: ${remotePath}. Use --overwrite to overwrite it.`,
|
|
924
|
-
{
|
|
925
|
-
context: { remotePath }
|
|
926
|
-
}
|
|
927
|
-
);
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
async assertLocalOverwrite(localPath, overwrite = false) {
|
|
931
|
-
if (overwrite) {
|
|
932
|
-
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));
|
|
933
1189
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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));
|
|
943
1205
|
}
|
|
944
|
-
|
|
945
|
-
|
|
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
|
+
}
|
|
946
1261
|
|
|
947
|
-
// src/client/download.ts
|
|
948
1262
|
async function download(options) {
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
+
}
|
|
955
1270
|
}
|
|
956
1271
|
|
|
957
|
-
// src/client/upload.ts
|
|
958
1272
|
async function upload(options) {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1273
|
+
const client = new ScpNextClientImpl(options);
|
|
1274
|
+
let result;
|
|
1275
|
+
try {
|
|
1276
|
+
result = await client.upload(options.localPath, options.remotePath, options);
|
|
1277
|
+
}
|
|
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
|
+
}
|
|
963
1287
|
await client.close();
|
|
964
|
-
|
|
1288
|
+
return result;
|
|
965
1289
|
}
|
|
966
1290
|
|
|
967
|
-
// src/cli/commands/download.ts
|
|
968
|
-
import { Command } from "commander";
|
|
969
|
-
|
|
970
|
-
// src/cli/operands.ts
|
|
971
1291
|
function usageFor(operation) {
|
|
972
|
-
|
|
1292
|
+
return `scp-next ${operation} <source> <destination> [options]`;
|
|
973
1293
|
}
|
|
974
1294
|
function assertSourceDestination(operation, source, destination) {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
${usageFor(operation)}`);
|
|
980
|
-
|
|
981
|
-
if (!destination) {
|
|
982
|
-
throw new ValidationError(`Missing destination path.
|
|
983
|
-
|
|
984
|
-
Usage:
|
|
985
|
-
${usageFor(operation)}`);
|
|
986
|
-
}
|
|
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
|
+
}
|
|
987
1301
|
}
|
|
988
1302
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
if (!Number.isFinite(parsed)) {
|
|
999
|
-
throw new Error(`${name} must be a number.`);
|
|
1000
|
-
}
|
|
1001
|
-
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;
|
|
1002
1312
|
}
|
|
1003
1313
|
function readEnvironment(env = process.env) {
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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;
|
|
1021
1340
|
}
|
|
1022
1341
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
agent: z.string().optional(),
|
|
1037
|
-
timeout: z.number().int().optional(),
|
|
1038
|
-
hostFingerprint: z.string().optional(),
|
|
1039
|
-
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()
|
|
1040
1355
|
});
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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()
|
|
1047
1365
|
});
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1366
|
+
const jobSchema = transferSchema.extend({
|
|
1367
|
+
operation: z.enum(["upload", "download"]),
|
|
1368
|
+
profile: z.string().optional(),
|
|
1369
|
+
source: z.string(),
|
|
1370
|
+
destination: z.string()
|
|
1053
1371
|
});
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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();
|
|
1061
1381
|
async function fileExists(filePath) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1382
|
+
try {
|
|
1383
|
+
await access(filePath);
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1068
1389
|
}
|
|
1069
1390
|
async function findConfigFile(cwd = process.cwd()) {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1391
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
1392
|
+
const candidate = path.resolve(cwd, filename);
|
|
1393
|
+
if (await fileExists(candidate)) {
|
|
1394
|
+
return candidate;
|
|
1395
|
+
}
|
|
1074
1396
|
}
|
|
1075
|
-
|
|
1076
|
-
return void 0;
|
|
1397
|
+
return undefined;
|
|
1077
1398
|
}
|
|
1078
1399
|
async function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
}
|
|
1083
|
-
let raw;
|
|
1084
|
-
try {
|
|
1085
|
-
raw = await readFile3(configPath, "utf8");
|
|
1086
|
-
} catch (error) {
|
|
1087
|
-
throw new ConfigurationError(`Unable to read configuration file: ${configPath}`, {
|
|
1088
|
-
cause: error,
|
|
1089
|
-
context: { path: configPath }
|
|
1090
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
try {
|
|
1093
|
-
const parsed = JSON.parse(raw);
|
|
1094
|
-
const result = configSchema.safeParse(parsed);
|
|
1095
|
-
if (!result.success) {
|
|
1096
|
-
throw new ConfigurationError(`Invalid configuration file: ${result.error.message}`, {
|
|
1097
|
-
context: { path: configPath }
|
|
1098
|
-
});
|
|
1400
|
+
const configPath = explicitPath ? resolveLocalPath(explicitPath, cwd) : await findConfigFile(cwd);
|
|
1401
|
+
if (!configPath) {
|
|
1402
|
+
return { config: {}, directory: cwd };
|
|
1099
1403
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
+
});
|
|
1108
1436
|
}
|
|
1109
|
-
throw new ConfigurationError(`Invalid JSON in configuration file: ${configPath}`, {
|
|
1110
|
-
cause: error,
|
|
1111
|
-
context: { path: configPath }
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
1437
|
}
|
|
1115
1438
|
|
|
1116
|
-
// src/config/resolve-config.ts
|
|
1117
|
-
import path6 from "path";
|
|
1118
|
-
|
|
1119
|
-
// src/config/jobs.ts
|
|
1120
1439
|
function selectJob(config, jobName) {
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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;
|
|
1128
1447
|
}
|
|
1129
1448
|
|
|
1130
|
-
// src/config/profiles.ts
|
|
1131
1449
|
function selectProfile(config, profileName) {
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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;
|
|
1142
1460
|
}
|
|
1143
1461
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
dryRun: false
|
|
1462
|
+
const DEFAULT_TRANSFER = {
|
|
1463
|
+
port: 22,
|
|
1464
|
+
recursive: false,
|
|
1465
|
+
overwrite: false,
|
|
1466
|
+
createDirectories: true,
|
|
1467
|
+
dryRun: false
|
|
1151
1468
|
};
|
|
1152
|
-
function assignDefined(target, source) {
|
|
1153
|
-
if (!source) {
|
|
1154
|
-
return target;
|
|
1155
|
-
}
|
|
1156
|
-
for (const [key, value] of Object.entries(source)) {
|
|
1157
|
-
if (value !== void 0) {
|
|
1158
|
-
Object.assign(target, { [key]: value });
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
return target;
|
|
1162
|
-
}
|
|
1163
1469
|
function rootServer(config) {
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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;
|
|
1180
1486
|
}
|
|
1181
1487
|
function jobTransfer(job) {
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
+
});
|
|
1193
1500
|
}
|
|
1194
1501
|
function resolveConfigRelatedPaths(resolved, operation, configDirectory) {
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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;
|
|
1209
1524
|
}
|
|
1210
1525
|
function resolveTransferConfig(input) {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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;
|
|
1265
1586
|
}
|
|
1266
1587
|
|
|
1267
|
-
// src/cli/output.ts
|
|
1268
1588
|
function writeDryRun(output, config) {
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
`
|
|
1272
|
-
|
|
1273
|
-
`);
|
|
1274
|
-
|
|
1275
|
-
`);
|
|
1276
|
-
|
|
1277
|
-
`);
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
function writeCommandResults(output, results, config) {
|
|
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
|
+
});
|
|
1280
1619
|
}
|
|
1281
1620
|
function createProgressReporter(output, quiet) {
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
+
};
|
|
1316
1659
|
}
|
|
1317
1660
|
function verbosePlan(output, config) {
|
|
1318
|
-
|
|
1319
|
-
`${JSON.stringify(
|
|
1320
|
-
redactSensitiveValues({
|
|
1661
|
+
const postUploadCommands = getPostUploadCommands(config);
|
|
1662
|
+
output.stderr.write(`${JSON.stringify(redactSensitiveValues({
|
|
1321
1663
|
operation: config.operation,
|
|
1322
1664
|
host: config.host,
|
|
1323
1665
|
port: config.port,
|
|
@@ -1327,241 +1669,260 @@ function verbosePlan(output, config) {
|
|
|
1327
1669
|
recursive: config.recursive,
|
|
1328
1670
|
overwrite: config.overwrite,
|
|
1329
1671
|
createDirectories: config.createDirectories,
|
|
1330
|
-
dryRun: config.dryRun
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
2
|
|
1334
|
-
)}
|
|
1335
|
-
`
|
|
1336
|
-
);
|
|
1337
|
-
}
|
|
1338
|
-
function formatBytes(bytes) {
|
|
1339
|
-
if (bytes < 1024) {
|
|
1340
|
-
return `${bytes} B`;
|
|
1341
|
-
}
|
|
1342
|
-
const units = ["KB", "MB", "GB", "TB"];
|
|
1343
|
-
let value = bytes / 1024;
|
|
1344
|
-
let unitIndex = 0;
|
|
1345
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
1346
|
-
value /= 1024;
|
|
1347
|
-
unitIndex += 1;
|
|
1348
|
-
}
|
|
1349
|
-
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
|
1672
|
+
dryRun: config.dryRun,
|
|
1673
|
+
postUploadCommands: postUploadCommands?.map((_command, index) => `command-${index + 1}`)
|
|
1674
|
+
}), null, 2)}\n`);
|
|
1350
1675
|
}
|
|
1351
1676
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1677
|
+
function addTransferOptions(command, options = {}) {
|
|
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;
|
|
1706
|
+
}
|
|
1707
|
+
function collectValues(value, previous) {
|
|
1708
|
+
return [...(previous ?? []), value];
|
|
1379
1709
|
}
|
|
1380
1710
|
function toCliOptions(options) {
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
+
};
|
|
1396
1727
|
}
|
|
1397
1728
|
async function resolveForCli(input) {
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
+
});
|
|
1410
1744
|
}
|
|
1411
1745
|
async function executeResolvedTransfer(config, handlers, output, commandOptions) {
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
|
1427
1774
|
};
|
|
1428
|
-
if (onProgress)
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
}
|
|
1432
|
-
const downloadOptions = {
|
|
1433
|
-
...config,
|
|
1434
|
-
remotePath: config.source,
|
|
1435
|
-
localPath: config.destination
|
|
1436
|
-
};
|
|
1437
|
-
if (onProgress) downloadOptions.onProgress = onProgress;
|
|
1438
|
-
await handlers.download(downloadOptions);
|
|
1775
|
+
if (onProgress)
|
|
1776
|
+
downloadOptions.onProgress = onProgress;
|
|
1777
|
+
await handlers.download(downloadOptions);
|
|
1439
1778
|
}
|
|
1440
1779
|
|
|
1441
|
-
// src/cli/commands/download.ts
|
|
1442
1780
|
function createDownloadCommand(handlers, output, cwd) {
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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);
|
|
1451
1796
|
});
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
addTransferOptions(command);
|
|
1455
|
-
return command;
|
|
1797
|
+
addTransferOptions(command);
|
|
1798
|
+
return command;
|
|
1456
1799
|
}
|
|
1457
1800
|
|
|
1458
|
-
// src/cli/commands/run.ts
|
|
1459
|
-
import { Command as Command2 } from "commander";
|
|
1460
1801
|
function createRunCommand(handlers, output, cwd) {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
options
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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;
|
|
1475
1820
|
}
|
|
1476
1821
|
|
|
1477
|
-
// src/cli/commands/upload.ts
|
|
1478
|
-
import { Command as Command3 } from "commander";
|
|
1479
1822
|
function createUploadCommand(handlers, output, cwd) {
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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);
|
|
1488
1838
|
});
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
addTransferOptions(command);
|
|
1492
|
-
return command;
|
|
1839
|
+
addTransferOptions(command, { postUploadCommand: true });
|
|
1840
|
+
return command;
|
|
1493
1841
|
}
|
|
1494
1842
|
|
|
1495
|
-
// src/cli/index.ts
|
|
1496
1843
|
function readPackageVersion(currentModuleUrl = import.meta.url) {
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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";
|
|
1501
1848
|
}
|
|
1502
1849
|
function isCliEntrypoint(currentModuleUrl = import.meta.url, argv = process.argv) {
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
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
|
+
}
|
|
1512
1860
|
}
|
|
1513
1861
|
async function runCli(options = {}) {
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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" });
|
|
1533
1884
|
return 0;
|
|
1534
|
-
}
|
|
1535
|
-
output.stderr.write(`${formatErrorMessage(error.message)}
|
|
1536
|
-
`);
|
|
1537
|
-
return error.exitCode || 1;
|
|
1538
1885
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
+
}
|
|
1557
1920
|
}
|
|
1558
1921
|
if (isCliEntrypoint()) {
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1922
|
+
void runCli().then((exitCode) => {
|
|
1923
|
+
process.exitCode = exitCode;
|
|
1924
|
+
});
|
|
1562
1925
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
};
|
|
1567
|
-
//# sourceMappingURL=index.js.map
|
|
1926
|
+
|
|
1927
|
+
export { isCliEntrypoint, runCli };
|
|
1928
|
+
//# sourceMappingURL=index.js.map
|