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/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
- // src/cli/index.ts
4
- import { readFileSync, realpathSync } from "fs";
5
- import { dirname, resolve } from "path";
6
- import { fileURLToPath } from "url";
7
- import { Command as Command4, CommanderError } from "commander";
8
-
9
- // src/client/client.ts
10
- import { access as access4, mkdir as mkdir2, stat as stat3 } from "fs/promises";
11
- import path4 from "path";
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
- var PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
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
- const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
31
- return SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive));
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
- if (typeof value === "string") {
35
- return value.replace(PRIVATE_KEY_PATTERN, "[REDACTED]");
36
- }
37
- if (Array.isArray(value)) {
38
- const redacted = value.map((item) => redactSensitiveValues(item));
39
- return redacted;
40
- }
41
- if (value && typeof value === "object") {
42
- const redacted = {};
43
- for (const [key, nestedValue] of Object.entries(value)) {
44
- redacted[key] = isSensitiveKey(key) ? "[REDACTED]" : redactSensitiveValues(nestedValue);
45
- }
46
- return redacted;
47
- }
48
- return value;
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
- if (error instanceof Error) {
52
- return String(redactSensitiveValues(error.message));
53
- }
54
- return String(redactSensitiveValues(error));
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
- // src/errors/index.ts
58
- var ScpNextError = class extends Error {
59
- code;
60
- context;
61
- constructor(message, options = {}) {
62
- super(message, { cause: options.cause });
63
- this.name = new.target.name;
64
- this.code = options.code ?? "SCP_NEXT_ERROR";
65
- this.context = redactSensitiveValues(options.context ?? {});
66
- }
67
- };
68
- var ConfigurationError = class extends ScpNextError {
69
- constructor(message, options = {}) {
70
- super(message, { ...options, code: "SCP_NEXT_CONFIGURATION_ERROR" });
71
- }
72
- };
73
- var ValidationError = class extends ScpNextError {
74
- constructor(message, options = {}) {
75
- super(message, { ...options, code: "SCP_NEXT_VALIDATION_ERROR" });
76
- }
77
- };
78
- var AuthenticationError = class extends ScpNextError {
79
- constructor(message, options = {}) {
80
- super(message, { ...options, code: "SCP_NEXT_AUTHENTICATION_ERROR" });
81
- }
82
- };
83
- var ConnectionError = class extends ScpNextError {
84
- constructor(message, options = {}) {
85
- super(message, { ...options, code: "SCP_NEXT_CONNECTION_ERROR" });
86
- }
87
- };
88
- var TransferError = class extends ScpNextError {
89
- constructor(message, options = {}) {
90
- super(message, { ...options, code: "SCP_NEXT_TRANSFER_ERROR" });
91
- }
92
- };
93
- var FileSystemError = class extends ScpNextError {
94
- constructor(message, options = {}) {
95
- super(message, { ...options, code: "SCP_NEXT_FILESYSTEM_ERROR" });
96
- }
97
- };
98
- var HostVerificationError = class extends ScpNextError {
99
- constructor(message, options = {}) {
100
- super(message, { ...options, code: "SCP_NEXT_HOST_VERIFICATION_ERROR" });
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
- if (error instanceof ScpNextError) {
105
- return error;
106
- }
107
- const message = error instanceof Error ? error.message : "Unexpected scp-next error.";
108
- const lowerMessage = message.toLowerCase();
109
- if (lowerMessage.includes("authentication") || lowerMessage.includes("permission denied")) {
110
- return new AuthenticationError("SSH authentication failed.", { cause: error });
111
- }
112
- if (lowerMessage.includes("host verification") || lowerMessage.includes("host key") || lowerMessage.includes("fingerprint") || lowerMessage.includes("host") && lowerMessage.includes("denied")) {
113
- return new HostVerificationError("SSH host verification failed.", { cause: error });
114
- }
115
- if (lowerMessage.includes("timed out") || lowerMessage.includes("connection") || lowerMessage.includes("econn")) {
116
- return new ConnectionError("SSH connection failed.", { cause: error });
117
- }
118
- return new TransferError(String(redactSensitiveValues(message)), { cause: error });
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
- if (inputPath === "~") {
132
- return os.homedir();
133
- }
134
- if (inputPath.startsWith(`~${path.sep}`) || inputPath.startsWith("~/")) {
135
- return path.join(os.homedir(), inputPath.slice(2));
136
- }
137
- return inputPath;
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
- const expanded = expandHome(inputPath);
141
- return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(baseDir, expanded);
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
- try {
145
- await access(resolveLocalPath(inputPath), constants.F_OK);
146
- } catch (error) {
147
- throw new FileSystemError(`Local path does not exist: ${inputPath}`, {
148
- cause: error,
149
- context: { path: inputPath }
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
- const resolved = resolveLocalPath(inputPath);
155
- const parent = path.dirname(resolved);
156
- if (createDirectories) {
157
- await mkdir(parent, { recursive: true });
158
- return;
159
- }
160
- try {
161
- await access(parent, constants.W_OK);
162
- } catch (error) {
163
- throw new FileSystemError(`Local destination parent does not exist or is not writable: ${parent}`, {
164
- cause: error,
165
- context: { path: inputPath }
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
- const stats = await stat(resolveLocalPath(inputPath));
171
- if (stats.isDirectory()) {
172
- return "directory";
173
- }
174
- if (stats.isFile()) {
175
- return "file";
176
- }
177
- throw new ValidationError(`Local path must be a file or directory: ${inputPath}`, {
178
- context: { path: inputPath }
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
- return fingerprint.trim().replace(/^SHA256:/i, "").replace(/^sha256:/i, "").replace(/=+$/g, "");
217
+ return fingerprint
218
+ .trim()
219
+ .replace(/^SHA256:/i, "")
220
+ .replace(/^sha256:/i, "")
221
+ .replace(/=+$/g, "");
185
222
  }
186
223
  function isHexFingerprint(fingerprint) {
187
- return /^[0-9a-f]+$/i.test(fingerprint);
224
+ return /^[0-9a-f]+$/i.test(fingerprint);
188
225
  }
189
226
  function fingerprintCandidates(fingerprint) {
190
- const normalized = normalizeFingerprint(fingerprint);
191
- const candidates = /* @__PURE__ */ new Set([normalized]);
192
- if (isHexFingerprint(normalized)) {
193
- candidates.add(normalized.toLowerCase());
194
- } else {
195
- try {
196
- candidates.add(Buffer.from(normalized, "base64").toString("hex"));
197
- } catch {
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
- const normalizedActual = normalizeFingerprint(actual);
204
- const normalizedExpected = normalizeFingerprint(expected);
205
- if (normalizedActual === normalizedExpected) {
206
- return true;
207
- }
208
- return isHexFingerprint(normalizedActual) && isHexFingerprint(normalizedExpected) && normalizedActual.toLowerCase() === normalizedExpected.toLowerCase();
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
- return [
212
- "",
213
- "Typical fix:",
214
- ` ssh ${host}`,
215
- "",
216
- "If you trust the host key, accept it and retry the scp-next command.",
217
- "Alternatively configure hostFingerprint or knownHostsFile explicitly."
218
- ].join("\n");
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
- const fingerprints = /* @__PURE__ */ new Set();
222
- const knownHostsFile = options.knownHostsFile ?? "~/.ssh/known_hosts";
223
- if (!options.host) {
224
- throw new HostVerificationError("A host is required for SSH host verification.");
225
- }
226
- if (options.hostFingerprint) {
227
- for (const fingerprint of fingerprintCandidates(options.hostFingerprint)) {
228
- fingerprints.add(fingerprint);
229
- }
230
- }
231
- try {
232
- await access2(expandHome(knownHostsFile));
233
- const content = await readFile(expandHome(knownHostsFile), "utf8");
234
- for (const line of content.split(/\r?\n/)) {
235
- const trimmed = line.trim();
236
- if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("@")) {
237
- continue;
238
- }
239
- const [hosts, algorithm, key] = trimmed.split(/\s+/);
240
- const hostPatterns = hosts?.split(",") ?? [];
241
- const portHost = options.port ? `[${options.host}]:${options.port}` : void 0;
242
- const matchesHost = hostPatterns.includes(options.host) || Boolean(portHost && hostPatterns.includes(portHost));
243
- if (!hosts || !algorithm || !key || !matchesHost) {
244
- continue;
245
- }
246
- const keyBuffer = Buffer.from(key, "base64");
247
- fingerprints.add(createHash("sha256").update(keyBuffer).digest("base64").replace(/=+$/g, ""));
248
- fingerprints.add(createHash("sha256").update(keyBuffer).digest("hex"));
249
- }
250
- } catch (error) {
251
- if (options.knownHostsFile || !options.hostFingerprint) {
252
- throw new HostVerificationError(
253
- "SSH host verification requires hostFingerprint or an accessible knownHostsFile.",
254
- { cause: error, context: { knownHostsFile } }
255
- );
256
- }
257
- }
258
- if (fingerprints.size === 0) {
259
- throw new HostVerificationError(
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
- return (hashedKey) => {
269
- for (const expected of allowedFingerprints) {
270
- if (isFingerprintMatch(hashedKey, expected)) {
271
- return true;
272
- }
273
- }
274
- return false;
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
- // src/client/transport.ts
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 !== void 0) connectOptions.host = options.host;
300
- if (options.port !== void 0) connectOptions.port = options.port;
301
- if (options.username !== void 0) connectOptions.username = options.username;
302
- if (options.password !== void 0) connectOptions.password = options.password;
303
- if (options.privateKey !== void 0) {
304
- connectOptions.privateKey = options.privateKey;
305
- } else if (options.privateKeyFile) {
306
- connectOptions.privateKey = await readFile2(expandHome(options.privateKeyFile), "utf8");
307
- }
308
- if (options.passphrase !== void 0) connectOptions.passphrase = options.passphrase;
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.timeout !== void 0) connectOptions.readyTimeout = options.timeout;
314
- if (hostVerifier) {
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
- try {
319
- await this.client.connect(connectOptions);
320
- } catch (error) {
321
- const converted = toScpNextError(error);
322
- if (converted instanceof HostVerificationError) {
323
- throw new HostVerificationError(
324
- `SSH host verification failed during connection: ${formatErrorMessage(
325
- error
326
- )}.${hostVerificationTroubleshooting(options.host)}`,
327
- { cause: error, context: connectionContext(options) }
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
- step(transferredBytes, chunkBytes, totalBytes) {
383
- onStep({ transferredBytes, chunkBytes, totalBytes });
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
- return {
390
- host: options.host,
391
- port: options.port,
392
- username: options.username
393
- };
428
+ return {
429
+ host: options.host,
430
+ port: options.port,
431
+ username: options.username
432
+ };
394
433
  }
395
434
  function isLikelyHostVerificationFailure(error) {
396
- const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
397
- return message.includes("host") && (message.includes("verif") || message.includes("fingerprint") || message.includes("host key") || message.includes("key mismatch") || message.includes("denied"));
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
- const displayHost = host ?? "<host>";
401
- return [
402
- "",
403
- "",
404
- "Typical fix:",
405
- ` ssh ${displayHost}`,
406
- "",
407
- "If plain ssh works but scp-next fails, check that the host entry is available in",
408
- "`~/.ssh/known_hosts` for the same host and port used by scp-next. For non-default ports,",
409
- "OpenSSH usually stores entries as `[host]:port`.",
410
- "You can also configure hostFingerprint or knownHostsFile explicitly."
411
- ].join("\n");
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
- // src/client/walk.ts
415
- import { readdir, stat as stat2 } from "fs/promises";
416
- import path3 from "path";
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
- if (!inputPath || inputPath.trim().length === 0) {
422
- throw new ValidationError(`Invalid ${label}: remote paths must not be empty.`, {
423
- context: { path: inputPath }
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
- assertRemotePath(inputPath);
429
- return path2.posix.normalize(inputPath.replace(/\\/g, "/"));
654
+ assertRemotePath(inputPath);
655
+ return path.posix.normalize(inputPath.replace(/\\/g, "/"));
430
656
  }
431
657
  function restoreMsysConvertedRemotePath(inputPath) {
432
- const normalized = inputPath.replace(/\\/g, "/");
433
- const match = /^[A-Za-z]:\/(?:Program Files\/Git|Program Files \(x86\)\/Git|Git|msys64)\/(.+)$/i.exec(
434
- normalized
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
- const joined = path2.posix.join(...parts);
440
- assertRemotePath(joined);
441
- return joined;
663
+ const joined = path.posix.join(...parts);
664
+ assertRemotePath(joined);
665
+ return joined;
442
666
  }
443
667
  function remoteDirname(inputPath) {
444
- assertRemotePath(inputPath);
445
- return path2.posix.dirname(inputPath);
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
- const resolvedRoot = resolveLocalPath(rootPath);
451
- const stats = await stat2(resolvedRoot);
452
- if (stats.isFile()) {
453
- return [
454
- {
455
- absolutePath: resolvedRoot,
456
- relativePath: path3.basename(resolvedRoot),
457
- size: stats.size
458
- }
459
- ];
460
- }
461
- const files = [];
462
- async function visit(directory) {
463
- const entries = await readdir(directory, { withFileTypes: true });
464
- for (const entry of entries) {
465
- const absolutePath = path3.join(directory, entry.name);
466
- if (entry.isDirectory()) {
467
- await visit(absolutePath);
468
- } else if (entry.isFile()) {
469
- const entryStats = await stat2(absolutePath);
470
- files.push({
471
- absolutePath,
472
- relativePath: path3.relative(resolvedRoot, absolutePath),
473
- size: entryStats.size
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
- await visit(resolvedRoot);
479
- return files;
702
+ await visit(resolvedRoot);
703
+ return files;
480
704
  }
481
705
  async function walkRemoteFiles(transport, rootPath) {
482
- const rootEntries = await transport.list(rootPath);
483
- const files = [];
484
- async function visit(directory, relativeBase, entries) {
485
- for (const entry of entries) {
486
- const fullPath = remoteJoin(directory, entry.name);
487
- const relativePath = relativeBase ? remoteJoin(relativeBase, entry.name) : entry.name;
488
- if (entry.type === "d") {
489
- await visit(fullPath, relativePath, await transport.list(fullPath));
490
- } else if (entry.type === "-") {
491
- files.push({
492
- remotePath: fullPath,
493
- relativePath,
494
- size: entry.size
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
- await visit(rootPath, "", rootEntries);
500
- return files;
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
- if (port === void 0) {
507
- return;
508
- }
509
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
510
- throw new ValidationError("Port must be an integer between 1 and 65535.", {
511
- context: { port }
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
- if (timeout === void 0) {
517
- return;
518
- }
519
- if (!Number.isFinite(timeout) || timeout <= 0) {
520
- throw new ValidationError("Timeout must be a positive number.", {
521
- context: { timeout }
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
- if (!options.host) {
527
- throw new ValidationError("Host is required.");
528
- }
529
- if (!options.username) {
530
- throw new ValidationError("Username is required.");
531
- }
532
- validatePort(options.port);
533
- validateTimeout(options.timeout);
534
- if (options.privateKeyFile) {
535
- try {
536
- await access3(resolveLocalPath(options.privateKeyFile));
537
- } catch (error) {
538
- throw new ValidationError(`Private key file does not exist: ${options.privateKeyFile}`, {
539
- cause: error,
540
- context: { privateKeyFile: options.privateKeyFile }
541
- });
542
- }
543
- }
544
- const hasAuthentication = Boolean(options.password) || Boolean(options.privateKey) || Boolean(options.privateKeyFile) || Boolean(options.agent) || Boolean(process.env.SSH_AUTH_SOCK);
545
- if (!hasAuthentication) {
546
- throw new ValidationError(
547
- "Authentication is required. Provide a password, private key, private key file, or SSH agent."
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
- if (!options.localPath) {
553
- throw new ValidationError("Missing localPath.");
554
- }
555
- if (!options.remotePath) {
556
- throw new ValidationError("Missing remotePath.");
557
- }
558
- assertRemotePath(options.remotePath, "remotePath");
559
- validateTimeout(options.timeout);
560
- await assertLocalPathExists(options.localPath);
561
- if (!options.dryRun) {
562
- await validateServerOptions(options);
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
- if (!options.remotePath) {
567
- throw new ValidationError("Missing remotePath.");
568
- }
569
- if (!options.localPath) {
570
- throw new ValidationError("Missing localPath.");
571
- }
572
- assertRemotePath(options.remotePath, "remotePath");
573
- validateTimeout(options.timeout);
574
- await assertDownloadDestination(options.localPath, options.createDirectories ?? true);
575
- if (!options.dryRun) {
576
- await validateServerOptions(options);
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
- if (!totalBytes || totalBytes <= 0) {
583
- return void 0;
584
- }
585
- return Math.min(100, Math.round(transferredBytes / totalBytes * 100));
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
- return { ...serverOptions, ...transferOptions ?? {} };
843
+ return { ...serverOptions, ...(transferOptions ?? {}) };
589
844
  }
590
845
  function shouldCreateDirectories(options) {
591
- return options.createDirectories ?? true;
846
+ return options.createDirectories ?? true;
592
847
  }
593
848
  function hasTrailingPathSeparator(inputPath) {
594
- return /[\\/]$/.test(inputPath);
849
+ return /[\\/]$/.test(inputPath);
595
850
  }
596
851
  function trimmedRemoteDirectory(remotePath) {
597
- return remotePath.replace(/\/+$/g, "") || "/";
852
+ return remotePath.replace(/\/+$/g, "") || "/";
598
853
  }
599
854
  function remoteBasename(remotePath) {
600
- return path4.posix.basename(trimmedRemoteDirectory(remotePath));
855
+ return path.posix.basename(trimmedRemoteDirectory(remotePath));
601
856
  }
602
857
  async function isExistingLocalDirectory(localPath) {
603
- try {
604
- return (await stat3(localPath)).isDirectory();
605
- } catch {
606
- return false;
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
- try {
611
- const stats = await stat3(localPath);
612
- if (stats.isDirectory()) {
613
- return;
614
- }
615
- throw new TransferError(`Local destination already exists and is not a directory: ${localPath}`, {
616
- context: { localPath }
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
- await mkdir2(localDestination, { recursive: true });
870
- }
871
- return path4.join(localDestination, remoteBasename(remotePath));
872
- }
873
- async resolveLocalDestinationForRemoteDirectory(options, remotePath) {
874
- const localDestination = resolveLocalPath(options.localPath);
875
- const isDirectoryLikeDestination = hasTrailingPathSeparator(options.localPath) || await isExistingLocalDirectory(localDestination);
876
- if (!isDirectoryLikeDestination) {
877
- return localDestination;
878
- }
879
- if (!await isExistingLocalDirectory(localDestination)) {
880
- if (!shouldCreateDirectories(options)) {
881
- throw new TransferError(`Local destination directory does not exist: ${localDestination}`, {
882
- context: { localPath: options.localPath }
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
- return path4.join(localDestination, remoteBasename(remotePath));
888
- }
889
- async ensureRemoteParent(remotePath, createDirectories = false) {
890
- const parent = remoteDirname(remotePath);
891
- if (parent === "." || parent === "/") {
892
- return;
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
- if (createDirectories) {
895
- await this.transport.mkdir(parent, true);
896
- return;
897
- }
898
- const exists = await this.transport.exists(parent);
899
- if (exists !== "d") {
900
- throw new TransferError(`Remote destination parent does not exist: ${parent}`, {
901
- context: { remotePath }
902
- });
903
- }
904
- }
905
- async ensureRemoteDirectory(remotePath) {
906
- const exists = await this.transport.exists(remotePath);
907
- if (exists && exists !== "d") {
908
- throw new TransferError(`Remote destination already exists and is not a directory: ${remotePath}`, {
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
- try {
935
- await access4(localPath);
936
- throw new TransferError(`Local destination already exists: ${localPath}`, {
937
- context: { localPath }
938
- });
939
- } catch (error) {
940
- if (error instanceof TransferError) {
941
- throw error;
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
- const client = new ScpNextClientImpl(options);
950
- try {
951
- await client.download(options.remotePath, options.localPath, options);
952
- } finally {
953
- await client.close();
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
- const client = new ScpNextClientImpl(options);
960
- try {
961
- await client.upload(options.localPath, options.remotePath, options);
962
- } finally {
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
- return `scp-next ${operation} <source> <destination> [options]`;
1292
+ return `scp-next ${operation} <source> <destination> [options]`;
973
1293
  }
974
1294
  function assertSourceDestination(operation, source, destination) {
975
- if (!source) {
976
- throw new ValidationError(`Missing source path.
977
-
978
- Usage:
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
- // src/cli/commands/shared.ts
990
- import { Option } from "commander";
991
-
992
- // src/config/environment.ts
993
- function parseInteger(value, name) {
994
- if (!value) {
995
- return void 0;
996
- }
997
- const parsed = Number.parseInt(value, 10);
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
- const result = {};
1005
- if (env.SCP_NEXT_HOST) result.host = env.SCP_NEXT_HOST;
1006
- if (env.SCP_NEXT_PORT) {
1007
- const port = parseInteger(env.SCP_NEXT_PORT, "SCP_NEXT_PORT");
1008
- if (port !== void 0) result.port = port;
1009
- }
1010
- if (env.SCP_NEXT_USERNAME) result.username = env.SCP_NEXT_USERNAME;
1011
- if (env.SCP_NEXT_PASSWORD) result.password = env.SCP_NEXT_PASSWORD;
1012
- if (env.SCP_NEXT_PRIVATE_KEY) result.privateKey = env.SCP_NEXT_PRIVATE_KEY;
1013
- if (env.SCP_NEXT_PRIVATE_KEY_FILE) result.privateKeyFile = env.SCP_NEXT_PRIVATE_KEY_FILE;
1014
- if (env.SCP_NEXT_PASSPHRASE) result.passphrase = env.SCP_NEXT_PASSPHRASE;
1015
- if (env.SCP_NEXT_TIMEOUT) {
1016
- const timeout = parseInteger(env.SCP_NEXT_TIMEOUT, "SCP_NEXT_TIMEOUT");
1017
- if (timeout !== void 0) result.timeout = timeout;
1018
- }
1019
- if (env.SCP_NEXT_PROFILE) result.profile = env.SCP_NEXT_PROFILE;
1020
- return result;
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
- // src/config/load-config.ts
1024
- import { access as access5, readFile as readFile3 } from "fs/promises";
1025
- import path5 from "path";
1026
- import { z } from "zod";
1027
- var CONFIG_FILENAMES = ["scp-next.config.json", ".scp-nextrc", ".scp-nextrc.json"];
1028
- var serverSchema = z.object({
1029
- host: z.string().optional(),
1030
- port: z.number().int().optional(),
1031
- username: z.string().optional(),
1032
- password: z.string().optional(),
1033
- privateKey: z.string().optional(),
1034
- privateKeyFile: z.string().optional(),
1035
- passphrase: z.string().optional(),
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
- var transferSchema = z.object({
1042
- recursive: z.boolean().optional(),
1043
- overwrite: z.boolean().optional(),
1044
- createDirectories: z.boolean().optional(),
1045
- dryRun: z.boolean().optional(),
1046
- timeout: z.number().int().optional()
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
- var jobSchema = transferSchema.extend({
1049
- operation: z.enum(["upload", "download"]),
1050
- profile: z.string().optional(),
1051
- source: z.string(),
1052
- destination: z.string()
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
- var configSchema = serverSchema.extend({
1055
- defaultProfile: z.string().optional(),
1056
- server: serverSchema.optional(),
1057
- transfer: transferSchema.optional(),
1058
- profiles: z.record(serverSchema).optional(),
1059
- jobs: z.record(jobSchema).optional()
1060
- }).passthrough();
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
- try {
1063
- await access5(filePath);
1064
- return true;
1065
- } catch {
1066
- return false;
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
- for (const filename of CONFIG_FILENAMES) {
1071
- const candidate = path5.resolve(cwd, filename);
1072
- if (await fileExists(candidate)) {
1073
- return candidate;
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
- const configPath = explicitPath ? resolveLocalPath(explicitPath, cwd) : await findConfigFile(cwd);
1080
- if (!configPath) {
1081
- return { config: {}, directory: cwd };
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
- return {
1101
- config: result.data,
1102
- path: configPath,
1103
- directory: path5.dirname(configPath)
1104
- };
1105
- } catch (error) {
1106
- if (error instanceof ConfigurationError) {
1107
- throw error;
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
- const job = config.jobs?.[jobName];
1122
- if (!job) {
1123
- throw new ConfigurationError(`Unknown job: ${jobName}`, {
1124
- context: { job: jobName }
1125
- });
1126
- }
1127
- return job;
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
- if (!profileName) {
1133
- return void 0;
1134
- }
1135
- const profile = config.profiles?.[profileName];
1136
- if (!profile) {
1137
- throw new ConfigurationError(`Unknown profile: ${profileName}`, {
1138
- context: { profile: profileName }
1139
- });
1140
- }
1141
- return profile;
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
- // src/config/resolve-config.ts
1145
- var DEFAULT_TRANSFER = {
1146
- port: 22,
1147
- recursive: false,
1148
- overwrite: false,
1149
- createDirectories: true,
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
- const result = {};
1165
- assignDefined(result, config.server);
1166
- assignDefined(result, {
1167
- host: config.host,
1168
- port: config.port,
1169
- username: config.username,
1170
- password: config.password,
1171
- privateKey: config.privateKey,
1172
- privateKeyFile: config.privateKeyFile,
1173
- passphrase: config.passphrase,
1174
- agent: config.agent,
1175
- timeout: config.timeout,
1176
- hostFingerprint: config.hostFingerprint,
1177
- knownHostsFile: config.knownHostsFile
1178
- });
1179
- return result;
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
- const result = {};
1183
- if (!job) {
1184
- return result;
1185
- }
1186
- return assignDefined(result, {
1187
- recursive: job.recursive,
1188
- overwrite: job.overwrite,
1189
- createDirectories: job.createDirectories,
1190
- dryRun: job.dryRun,
1191
- timeout: job.timeout
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
- const next = { ...resolved };
1196
- if (next.privateKeyFile && !next.privateKeyFile.startsWith("~")) {
1197
- next.privateKeyFile = path6.isAbsolute(next.privateKeyFile) ? next.privateKeyFile : resolveLocalPath(next.privateKeyFile, configDirectory);
1198
- }
1199
- if (next.knownHostsFile && !next.knownHostsFile.startsWith("~")) {
1200
- next.knownHostsFile = path6.isAbsolute(next.knownHostsFile) ? next.knownHostsFile : resolveLocalPath(next.knownHostsFile, configDirectory);
1201
- }
1202
- if (operation === "upload" && !next.source.startsWith("~")) {
1203
- next.source = path6.isAbsolute(next.source) ? next.source : resolveLocalPath(next.source, configDirectory);
1204
- }
1205
- if (operation === "download" && !next.destination.startsWith("~")) {
1206
- next.destination = path6.isAbsolute(next.destination) ? next.destination : resolveLocalPath(next.destination, configDirectory);
1207
- }
1208
- return next;
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
- const config = input.config ?? {};
1212
- const env = input.env ?? {};
1213
- const cli = input.cli ?? {};
1214
- const configDirectory = input.configDirectory ?? process.cwd();
1215
- const cwd = input.cwd ?? process.cwd();
1216
- const job = input.jobName ? selectJob(config, input.jobName) : void 0;
1217
- const operation = input.operation ?? job?.operation;
1218
- if (!operation) {
1219
- throw new Error("Transfer operation is required.");
1220
- }
1221
- const selectedProfileName = cli.profile ?? env.profile ?? job?.profile ?? config.defaultProfile;
1222
- const profile = selectProfile(config, selectedProfileName);
1223
- const resolved = {
1224
- ...DEFAULT_TRANSFER,
1225
- operation,
1226
- source: job?.source ?? "",
1227
- destination: job?.destination ?? ""
1228
- };
1229
- assignDefined(resolved, jobTransfer(job));
1230
- assignDefined(resolved, config.transfer);
1231
- assignDefined(resolved, rootServer(config));
1232
- assignDefined(resolved, profile);
1233
- assignDefined(resolved, {
1234
- host: env.host,
1235
- port: env.port,
1236
- username: env.username,
1237
- password: env.password,
1238
- privateKey: env.privateKey,
1239
- privateKeyFile: env.privateKeyFile,
1240
- passphrase: env.passphrase,
1241
- timeout: env.timeout
1242
- });
1243
- assignDefined(resolved, {
1244
- source: input.source,
1245
- destination: input.destination
1246
- });
1247
- assignDefined(resolved, cli);
1248
- if (selectedProfileName) {
1249
- resolved.profile = selectedProfileName;
1250
- }
1251
- const pathResolved = resolveConfigRelatedPaths(resolved, operation, configDirectory);
1252
- if (input.source && operation === "upload") {
1253
- pathResolved.source = resolveLocalPath(input.source, cwd);
1254
- }
1255
- if (operation === "upload") {
1256
- pathResolved.destination = restoreMsysConvertedRemotePath(pathResolved.destination);
1257
- }
1258
- if (operation === "download") {
1259
- pathResolved.source = restoreMsysConvertedRemotePath(pathResolved.source);
1260
- }
1261
- if (input.destination && operation === "download") {
1262
- pathResolved.destination = resolveLocalPath(input.destination, cwd);
1263
- }
1264
- return pathResolved;
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
- const destination = config.operation === "upload" ? `${config.username ?? "user"}@${config.host ?? "host"}:${config.destination}` : config.destination;
1270
- output.stdout.write(`Dry run: ${config.operation}
1271
- `);
1272
- output.stdout.write(`Source: ${config.source}
1273
- `);
1274
- output.stdout.write(`Destination: ${destination}
1275
- `);
1276
- output.stdout.write(`Recursive: ${config.recursive ? "yes" : "no"}
1277
- `);
1278
- output.stdout.write(`Overwrite: ${config.overwrite ? "yes" : "no"}
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
- if (quiet || !output.stderr.isTTY) {
1283
- return void 0;
1284
- }
1285
- const minimumUpdateIntervalMs = 500;
1286
- let lastLineLength = 0;
1287
- let lastPercentage;
1288
- let lastWriteAt = 0;
1289
- let completed = false;
1290
- return (progress) => {
1291
- if (completed) {
1292
- return;
1293
- }
1294
- const now = Date.now();
1295
- const totalBytes = progress.totalBytes;
1296
- const isComplete = progress.percentage === 100 || totalBytes !== void 0 && totalBytes > 0 && progress.transferredBytes >= totalBytes;
1297
- const percentageChanged = progress.percentage !== void 0 && progress.percentage !== lastPercentage;
1298
- if (!isComplete && !percentageChanged && lastWriteAt !== 0 && now - lastWriteAt < minimumUpdateIntervalMs) {
1299
- return;
1300
- }
1301
- const total = progress.totalBytes ? formatBytes(progress.totalBytes) : "?";
1302
- const transferred = formatBytes(progress.transferredBytes);
1303
- const percentage2 = progress.percentage === void 0 ? "" : ` (${progress.percentage}%)`;
1304
- const currentFile = progress.currentFile ? `: ${progress.currentFile}` : "";
1305
- const line = `${progress.operation === "upload" ? "Uploading" : "Downloading"}${currentFile} ${transferred} / ${total}${percentage2}`;
1306
- const padding = " ".repeat(Math.max(0, lastLineLength - line.length));
1307
- output.stderr.write(`\r${line}${padding}`);
1308
- lastLineLength = line.length;
1309
- lastPercentage = progress.percentage;
1310
- lastWriteAt = now;
1311
- if (isComplete) {
1312
- output.stderr.write("\n");
1313
- completed = true;
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
- output.stderr.write(
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
- null,
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
- // src/cli/commands/shared.ts
1353
- function addTransferOptions(command) {
1354
- command.option("--host <host>", "SSH server host");
1355
- command.addOption(new Option("--port <port>", "SSH server port").argParser(parseInteger2));
1356
- command.option("--username <username>", "SSH username");
1357
- command.option("--password <password>", "SSH password (prefer environment variables or key files)");
1358
- command.option("--private-key <privateKey>", "Private-key content");
1359
- command.option("--private-key-file <privateKeyFile>", "Private-key file path");
1360
- command.option("--passphrase <passphrase>", "Private-key passphrase");
1361
- command.option("--config <path>", "Configuration file path");
1362
- command.option("--profile <name>", "Named server profile");
1363
- command.option("--recursive", "Transfer directories recursively");
1364
- command.option("--overwrite", "Overwrite existing destination files");
1365
- command.option("--create-directories", "Create missing destination directories");
1366
- command.option("--no-create-directories", "Do not create missing destination directories");
1367
- command.option("--dry-run", "Resolve and validate without connecting or transferring");
1368
- command.addOption(new Option("--timeout <milliseconds>", "Connection/operation timeout").argParser(parseInteger2));
1369
- command.option("--verbose", "Print verbose diagnostic output");
1370
- command.option("--quiet", "Disable progress and non-error output");
1371
- return command;
1372
- }
1373
- function parseInteger2(value) {
1374
- const parsed = Number.parseInt(value, 10);
1375
- if (!Number.isFinite(parsed)) {
1376
- throw new Error("Expected a number.");
1377
- }
1378
- return parsed;
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
- return {
1382
- host: options.host,
1383
- port: options.port,
1384
- username: options.username,
1385
- password: options.password,
1386
- privateKey: options.privateKey,
1387
- privateKeyFile: options.privateKeyFile,
1388
- passphrase: options.passphrase,
1389
- profile: options.profile,
1390
- recursive: options.recursive,
1391
- overwrite: options.overwrite,
1392
- createDirectories: options.createDirectories,
1393
- dryRun: options.dryRun,
1394
- timeout: options.timeout
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
- const loaded = await loadConfig(input.options.config, input.cwd);
1399
- return resolveTransferConfig({
1400
- operation: input.operation,
1401
- source: input.source,
1402
- destination: input.destination,
1403
- jobName: input.jobName,
1404
- cli: toCliOptions(input.options),
1405
- env: readEnvironment(),
1406
- config: loaded.config,
1407
- configDirectory: loaded.directory,
1408
- cwd: input.cwd
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
- if (commandOptions.verbose) {
1413
- verbosePlan(output, config);
1414
- }
1415
- if (config.dryRun) {
1416
- if (!commandOptions.quiet) {
1417
- writeDryRun(output, config);
1418
- }
1419
- return;
1420
- }
1421
- const onProgress = createProgressReporter(output, commandOptions.quiet);
1422
- if (config.operation === "upload") {
1423
- const uploadOptions = {
1424
- ...config,
1425
- localPath: config.source,
1426
- remotePath: config.destination
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) uploadOptions.onProgress = onProgress;
1429
- await handlers.upload(uploadOptions);
1430
- return;
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
- const command = new Command("download").description("Download a remote source path to a local destination path").usage("<source> <destination> [options]").argument("[source]", "Remote file or directory path").argument("[destination]", "Local file or directory").action(async (source, destination, options) => {
1444
- assertSourceDestination("download", source, destination);
1445
- const resolved = await resolveForCli({
1446
- operation: "download",
1447
- source,
1448
- destination,
1449
- options,
1450
- cwd
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
- await executeResolvedTransfer(resolved, handlers, output, options);
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
- const command = new Command2("run").description("Run a configured transfer job").usage("<job> [source] [destination] [options]").argument("<job>", "Configured job name").argument("[source]", "Optional source override").argument("[destination]", "Optional destination override").action(
1462
- async (jobName, source, destination, options) => {
1463
- const resolved = await resolveForCli({
1464
- source,
1465
- destination,
1466
- jobName,
1467
- options,
1468
- cwd
1469
- });
1470
- await executeResolvedTransfer(resolved, handlers, output, options);
1471
- }
1472
- );
1473
- addTransferOptions(command);
1474
- return command;
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
- const command = new Command3("upload").description("Upload a local source path to a remote destination path").usage("<source> <destination> [options]").argument("[source]", "Local file or directory").argument("[destination]", "Remote file or directory path").action(async (source, destination, options) => {
1481
- assertSourceDestination("upload", source, destination);
1482
- const resolved = await resolveForCli({
1483
- operation: "upload",
1484
- source,
1485
- destination,
1486
- options,
1487
- cwd
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
- await executeResolvedTransfer(resolved, handlers, output, options);
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
- const currentFile = fileURLToPath(currentModuleUrl);
1498
- const packageJsonPath = resolve(dirname(currentFile), "..", "..", "package.json");
1499
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
1500
- return packageJson.version ?? "0.0.0";
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
- const executedFile = argv[1];
1504
- if (!executedFile) {
1505
- return false;
1506
- }
1507
- try {
1508
- return realpathSync(fileURLToPath(currentModuleUrl)) === realpathSync(executedFile);
1509
- } catch {
1510
- return false;
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
- const output = options.output ?? { stdout: process.stdout, stderr: process.stderr };
1515
- const cwd = options.cwd ?? process.cwd();
1516
- const handlers = options.handlers ?? { upload, download };
1517
- const program = new Command4();
1518
- program.name("scp-next").description("Secure file transfers over SSH using SFTP").version(readPackageVersion()).configureOutput({
1519
- writeOut: (message) => output.stdout.write(message),
1520
- writeErr: (message) => output.stderr.write(message)
1521
- }).configureHelp({
1522
- subcommandTerm: (command) => `${command.name()} ${command.usage()}`
1523
- }).showHelpAfterError().exitOverride();
1524
- program.addCommand(createUploadCommand(handlers, output, cwd));
1525
- program.addCommand(createDownloadCommand(handlers, output, cwd));
1526
- program.addCommand(createRunCommand(handlers, output, cwd));
1527
- try {
1528
- await program.parseAsync(options.argv ?? process.argv, { from: "node" });
1529
- return 0;
1530
- } catch (error) {
1531
- if (error instanceof CommanderError) {
1532
- if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
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
- const verbose = (options.argv ?? process.argv).includes("--verbose");
1540
- if (error instanceof ScpNextError) {
1541
- output.stderr.write(`Error: ${formatErrorMessage(error.message)}
1542
- `);
1543
- if (verbose && error.cause instanceof Error) {
1544
- output.stderr.write(`${formatErrorMessage(error.cause.stack ?? error.cause.message)}
1545
- `);
1546
- }
1547
- return 1;
1548
- }
1549
- output.stderr.write(`Error: ${formatErrorMessage(error)}
1550
- `);
1551
- if (verbose && error instanceof Error && error.stack) {
1552
- output.stderr.write(`${formatErrorMessage(error.stack)}
1553
- `);
1554
- }
1555
- return 1;
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
- void runCli().then((exitCode) => {
1560
- process.exitCode = exitCode;
1561
- });
1922
+ void runCli().then((exitCode) => {
1923
+ process.exitCode = exitCode;
1924
+ });
1562
1925
  }
1563
- export {
1564
- isCliEntrypoint,
1565
- runCli
1566
- };
1567
- //# sourceMappingURL=index.js.map
1926
+
1927
+ export { isCliEntrypoint, runCli };
1928
+ //# sourceMappingURL=index.js.map