scp-next 1.0.0

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