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