sftp-push-sync 2.1.3 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1060 @@
1
+ /**
2
+ * SftpPushSyncApp.mjs
3
+ *
4
+ * @author Carsten Nichte, 2025 / https://carsten-nichte.de/
5
+ *
6
+ */
7
+ // src/core/SftpPushSyncApp.mjs
8
+ import fs from "fs";
9
+ import fsp from "fs/promises";
10
+ import path from "path";
11
+ import SftpClient from "ssh2-sftp-client";
12
+ import { minimatch } from "minimatch";
13
+ import pc from "picocolors";
14
+ import { createRequire } from "module";
15
+
16
+ import { SyncLogger } from "./SyncLogger.mjs";
17
+ import { ScanProgressController } from "./ScanProgressController.mjs";
18
+
19
+ import { toPosix, shortenPathForProgress } from "../helpers/directory.mjs";
20
+ import { createHashCache } from "../helpers/hashing.mjs";
21
+ import { walkLocal, walkRemote } from "../helpers/walkers.mjs";
22
+ import {
23
+ analyseDifferences,
24
+ computeRemoteDeletes,
25
+ } from "../helpers/compare.mjs";
26
+ import { performBypassOnly as performSidecarBypass } from "../helpers/sidecar.mjs";
27
+ import {
28
+ hr1,
29
+ hr2,
30
+ TAB_A,
31
+ TAB_B,
32
+ SPINNER_FRAMES,
33
+ } from "../helpers/progress-constants.mjs";
34
+
35
+ const require = createRequire(import.meta.url);
36
+ const pkg = require("../../package.json");
37
+
38
+ // Symbole & Format
39
+ const ADD = pc.green("+");
40
+ const CHA = pc.yellow("~");
41
+ const DEL = pc.red("-");
42
+ const EXC = pc.redBright("-");
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Fehlerhilfe SFTP
46
+ // ---------------------------------------------------------------------------
47
+ function describeSftpError(err) {
48
+ if (!err) return "";
49
+
50
+ const code = err.code || err.errno || "";
51
+ const msg = (err.message || "").toLowerCase();
52
+
53
+ if (code === "ENOTFOUND") {
54
+ return "Host not found (ENOTFOUND) – Check hostname or DNS entry.";
55
+ }
56
+ if (code === "EHOSTUNREACH") {
57
+ return "Host not reachable (EHOSTUNREACH) – Check network/firewall.";
58
+ }
59
+ if (code === "ECONNREFUSED") {
60
+ return "Connection refused (ECONNREFUSED) – Check the port or SSH service.";
61
+ }
62
+ if (code === "ECONNRESET") {
63
+ return "Connection was reset by the server (ECONNRESET).";
64
+ }
65
+ if (code === "ETIMEDOUT") {
66
+ return "Connection timeout (ETIMEDOUT) – Server is not responding or is blocked.";
67
+ }
68
+
69
+ if (msg.includes("all configured authentication methods failed")) {
70
+ return "Authentication failed – check your username/password or SSH keys.";
71
+ }
72
+ if (msg.includes("permission denied")) {
73
+ return "Access denied – check permissions on the server.";
74
+ }
75
+
76
+ return "";
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // App-Klasse
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export class SftpPushSyncApp {
84
+ /**
85
+ * options: {
86
+ * target,
87
+ * dryRun,
88
+ * runUploadList,
89
+ * runDownloadList,
90
+ * skipSync,
91
+ * cliLogLevel,
92
+ * configPath
93
+ * }
94
+ */
95
+ constructor(options = {}) {
96
+ this.options = options;
97
+
98
+ // Konfiguration
99
+ this.configRaw = null;
100
+ this.targetConfig = null;
101
+ this.connection = null;
102
+
103
+ // Patterns
104
+ this.includePatterns = [];
105
+ this.baseExcludePatterns = [];
106
+ this.uploadList = [];
107
+ this.downloadList = [];
108
+ this.excludePatterns = [];
109
+ this.autoExcluded = new Set();
110
+
111
+ // Dateitypen
112
+ this.textExt = [];
113
+ this.mediaExt = [];
114
+
115
+ // Log / Level
116
+ this.logLevel = "normal";
117
+ this.isVerbose = false;
118
+ this.isLaconic = false;
119
+ this.logger = null;
120
+
121
+ // Progress
122
+ this.scanChunk = 100;
123
+ this.analyzeChunk = 10;
124
+ this.parallelScan = true;
125
+
126
+ this.progressActive = false;
127
+ this.spinnerIndex = 0;
128
+
129
+ // Cleanup
130
+ this.cleanupEmptyDirsEnabled = true;
131
+ this.cleanupEmptyRoots = false;
132
+ this.dirStats = {
133
+ ensuredDirs: 0,
134
+ createdDirs: 0,
135
+ cleanupVisited: 0,
136
+ cleanupDeleted: 0,
137
+ };
138
+
139
+ // Cache
140
+ this.hashCache = null;
141
+ }
142
+
143
+ // ---------------------------------------------------------
144
+ // Logging-Helfer (Console + Logfile)
145
+ // ---------------------------------------------------------
146
+
147
+ _writeLogFile(line) {
148
+ if (this.logger) {
149
+ this.logger.writeLine(line);
150
+ }
151
+ }
152
+
153
+ _clearProgressLine() {
154
+ if (!process.stdout.isTTY || !this.progressActive) return;
155
+
156
+ process.stdout.write("\r");
157
+ process.stdout.write("\x1b[2K");
158
+ process.stdout.write("\x1b[1B");
159
+ process.stdout.write("\x1b[2K");
160
+ process.stdout.write("\x1b[1A");
161
+
162
+ this.progressActive = false;
163
+ }
164
+
165
+ _consoleAndLog(prefixForFile, ...msg) {
166
+ this._clearProgressLine();
167
+ console.log(...msg);
168
+ const line = msg
169
+ .map((m) => (typeof m === "string" ? m : String(m)))
170
+ .join(" ");
171
+ this._writeLogFile(prefixForFile ? prefixForFile + line : line);
172
+ }
173
+
174
+ log(...msg) {
175
+ this._consoleAndLog("", ...msg);
176
+ }
177
+
178
+ elog(...msg) {
179
+ this._consoleAndLog("[ERROR] ", ...msg);
180
+ }
181
+
182
+ wlog(...msg) {
183
+ this._consoleAndLog("[WARN] ", ...msg);
184
+ }
185
+
186
+ vlog(...msg) {
187
+ if (!this.isVerbose) return;
188
+ this._consoleAndLog("", ...msg);
189
+ }
190
+
191
+ // ---------------------------------------------------------
192
+ // Pattern-Helper
193
+ // ---------------------------------------------------------
194
+
195
+ matchesAny(patterns, relPath) {
196
+ if (!patterns || patterns.length === 0) return false;
197
+ return patterns.some((pattern) =>
198
+ minimatch(relPath, pattern, { dot: true })
199
+ );
200
+ }
201
+
202
+ isIncluded(relPath) {
203
+ if (
204
+ this.includePatterns.length > 0 &&
205
+ !this.matchesAny(this.includePatterns, relPath)
206
+ ) {
207
+ return false;
208
+ }
209
+
210
+ if (
211
+ this.excludePatterns.length > 0 &&
212
+ this.matchesAny(this.excludePatterns, relPath)
213
+ ) {
214
+ if (
215
+ this.uploadList.includes(relPath) ||
216
+ this.downloadList.includes(relPath)
217
+ ) {
218
+ this.autoExcluded.add(relPath);
219
+ }
220
+ return false;
221
+ }
222
+
223
+ return true;
224
+ }
225
+
226
+ isTextFile(relPath) {
227
+ const ext = path.extname(relPath).toLowerCase();
228
+ return this.textExt.includes(ext);
229
+ }
230
+
231
+ isMediaFile(relPath) {
232
+ const ext = path.extname(relPath).toLowerCase();
233
+ return this.mediaExt.includes(ext);
234
+ }
235
+
236
+ // ---------------------------------------------------------
237
+ // Progress-Balken (Phase 3, Verzeichnisse, Cleanup)
238
+ // ---------------------------------------------------------
239
+
240
+ updateProgress2(prefix, current, total, rel = "", suffix = "Files") {
241
+ const short = rel ? shortenPathForProgress(rel) : "";
242
+
243
+ const base =
244
+ total && total > 0
245
+ ? `${prefix}${current}/${total} ${suffix}`
246
+ : `${prefix}${current} ${suffix}`;
247
+
248
+ this._writeLogFile(
249
+ `[progress] ${base}${rel ? " – " + rel : ""}`
250
+ );
251
+
252
+ const frame = SPINNER_FRAMES[this.spinnerIndex];
253
+ this.spinnerIndex = (this.spinnerIndex + 1) % SPINNER_FRAMES.length;
254
+
255
+ if (!process.stdout.isTTY) {
256
+ if (total && total > 0) {
257
+ const percent = ((current / total) * 100).toFixed(1);
258
+ console.log(
259
+ `${TAB_A}${frame} ${prefix}${current}/${total} ${suffix} (${percent}%) – ${short}`
260
+ );
261
+ } else {
262
+ console.log(
263
+ `${TAB_A}${frame} ${prefix}${current} ${suffix} – ${short}`
264
+ );
265
+ }
266
+ return;
267
+ }
268
+
269
+ const width = process.stdout.columns || 80;
270
+
271
+ let line1;
272
+ if (total && total > 0) {
273
+ const percent = ((current / total) * 100).toFixed(1);
274
+ line1 = `${TAB_A}${frame} ${prefix}${current}/${total} ${suffix} (${percent}%)`;
275
+ } else {
276
+ line1 = `${TAB_A}${frame} ${prefix}${current} ${suffix}`;
277
+ }
278
+
279
+ let line2 = short || "";
280
+
281
+ if (line1.length > width) line1 = line1.slice(0, width - 1);
282
+ if (line2.length > width) line2 = line2.slice(0, width - 1);
283
+
284
+ process.stdout.write("\r" + line1.padEnd(width) + "\n");
285
+ process.stdout.write(line2.padEnd(width));
286
+ process.stdout.write("\x1b[1A");
287
+
288
+ this.progressActive = true;
289
+ }
290
+
291
+ // ---------------------------------------------------------
292
+ // Worker-Pool
293
+ // ---------------------------------------------------------
294
+
295
+ async runTasks(items, workerCount, handler, label = "Tasks") {
296
+ if (!items || items.length === 0) return;
297
+
298
+ const total = items.length;
299
+ let done = 0;
300
+ let index = 0;
301
+ const workers = [];
302
+ const actualWorkers = Math.max(1, Math.min(workerCount, total));
303
+
304
+ const worker = async () => {
305
+ // eslint-disable-next-line no-constant-condition
306
+ while (true) {
307
+ const i = index;
308
+ if (i >= total) break;
309
+ index += 1;
310
+ const item = items[i];
311
+
312
+ try {
313
+ await handler(item);
314
+ } catch (err) {
315
+ this.elog(
316
+ pc.red(`${TAB_A}⚠️ Error in ${label}:`),
317
+ err?.message || err
318
+ );
319
+ }
320
+
321
+ done += 1;
322
+ if (done === 1 || done % 10 === 0 || done === total) {
323
+ this.updateProgress2(`${label}: `, done, total, item.rel ?? "");
324
+ }
325
+ }
326
+ };
327
+
328
+ for (let i = 0; i < actualWorkers; i += 1) {
329
+ workers.push(worker());
330
+ }
331
+ await Promise.all(workers);
332
+ }
333
+
334
+ // ---------------------------------------------------------
335
+ // Helper: Verzeichnisse vorbereiten
336
+ // ---------------------------------------------------------
337
+
338
+ collectDirsFromChanges(changes) {
339
+ const dirs = new Set();
340
+
341
+ for (const item of changes) {
342
+ const rel = item.rel;
343
+ if (!rel) continue;
344
+
345
+ const parts = rel.split("/");
346
+ if (parts.length <= 1) continue;
347
+
348
+ let acc = "";
349
+ for (let i = 0; i < parts.length - 1; i += 1) {
350
+ acc = acc ? `${acc}/${parts[i]}` : parts[i];
351
+ dirs.add(acc);
352
+ }
353
+ }
354
+
355
+ return [...dirs].sort(
356
+ (a, b) => a.split("/").length - b.split("/").length
357
+ );
358
+ }
359
+
360
+ async ensureAllRemoteDirsExist(sftp, remoteRoot, toAdd, toUpdate) {
361
+ const dirs = this.collectDirsFromChanges([...toAdd, ...toUpdate]);
362
+ const total = dirs.length;
363
+ this.dirStats.ensuredDirs += total;
364
+
365
+ if (total === 0) return;
366
+
367
+ let current = 0;
368
+
369
+ for (const relDir of dirs) {
370
+ current += 1;
371
+ const remoteDir = path.posix.join(remoteRoot, relDir);
372
+
373
+ this.updateProgress2(
374
+ "Prepare dirs: ",
375
+ current,
376
+ total,
377
+ relDir,
378
+ "Folders"
379
+ );
380
+
381
+ try {
382
+ const exists = await sftp.exists(remoteDir);
383
+ if (!exists) {
384
+ await sftp.mkdir(remoteDir, true);
385
+ this.dirStats.createdDirs += 1;
386
+ this.vlog(`${TAB_A}${pc.dim("dir created:")} ${remoteDir}`);
387
+ } else {
388
+ this.vlog(`${TAB_A}${pc.dim("dir ok:")} ${remoteDir}`);
389
+ }
390
+ } catch (e) {
391
+ this.wlog(
392
+ pc.yellow("⚠️ Could not ensure directory:"),
393
+ remoteDir,
394
+ e?.message || e
395
+ );
396
+ }
397
+ }
398
+
399
+ this.updateProgress2("Prepare dirs: ", total, total, "done", "Folders");
400
+ process.stdout.write("\n");
401
+ this.progressActive = false;
402
+ }
403
+
404
+ // ---------------------------------------------------------
405
+ // Cleanup: leere Verzeichnisse löschen
406
+ // ---------------------------------------------------------
407
+
408
+ async cleanupEmptyDirs(sftp, rootDir, dryRun) {
409
+ const recurse = async (dir) => {
410
+ this.dirStats.cleanupVisited += 1;
411
+
412
+ const relForProgress = toPosix(path.relative(rootDir, dir)) || ".";
413
+
414
+ this.updateProgress2(
415
+ "Cleanup dirs: ",
416
+ this.dirStats.cleanupVisited,
417
+ 0,
418
+ relForProgress,
419
+ "Folders"
420
+ );
421
+
422
+ let hasFile = false;
423
+ const subdirs = [];
424
+ let items;
425
+
426
+ try {
427
+ items = await sftp.list(dir);
428
+ } catch (e) {
429
+ this.wlog(
430
+ pc.yellow("⚠️ Could not list directory during cleanup:"),
431
+ dir,
432
+ e?.message || e
433
+ );
434
+ return false;
435
+ }
436
+
437
+ for (const item of items) {
438
+ if (!item.name || item.name === "." || item.name === "..") continue;
439
+ if (item.type === "d") {
440
+ subdirs.push(item);
441
+ } else {
442
+ hasFile = true;
443
+ }
444
+ }
445
+
446
+ let allSubdirsEmpty = true;
447
+ for (const sub of subdirs) {
448
+ const full = path.posix.join(dir, sub.name);
449
+ const subEmpty = await recurse(full);
450
+ if (!subEmpty) {
451
+ allSubdirsEmpty = false;
452
+ }
453
+ }
454
+
455
+ const isRoot = dir === rootDir;
456
+ const isEmpty = !hasFile && allSubdirsEmpty;
457
+
458
+ if (isEmpty && (!isRoot || this.cleanupEmptyRoots)) {
459
+ const rel = relForProgress || ".";
460
+ if (dryRun) {
461
+ this.log(
462
+ `${TAB_A}${DEL} (DRY-RUN) Remove empty directory: ${rel}`
463
+ );
464
+ this.dirStats.cleanupDeleted += 1;
465
+ } else {
466
+ try {
467
+ await sftp.rmdir(dir, false);
468
+ this.log(`${TAB_A}${DEL} Removed empty directory: ${rel}`);
469
+ this.dirStats.cleanupDeleted += 1;
470
+ } catch (e) {
471
+ this.wlog(
472
+ pc.yellow("⚠️ Could not remove directory:"),
473
+ dir,
474
+ e?.message || e
475
+ );
476
+ return false;
477
+ }
478
+ }
479
+ }
480
+
481
+ return isEmpty;
482
+ };
483
+
484
+ await recurse(rootDir);
485
+
486
+ if (this.dirStats.cleanupVisited > 0) {
487
+ this.updateProgress2(
488
+ "Cleanup dirs: ",
489
+ this.dirStats.cleanupVisited,
490
+ this.dirStats.cleanupVisited,
491
+ "done",
492
+ "Folders"
493
+ );
494
+ process.stdout.write("\n");
495
+ this.progressActive = false;
496
+ }
497
+ }
498
+
499
+ // ---------------------------------------------------------
500
+ // Hauptlauf
501
+ // ---------------------------------------------------------
502
+
503
+ async run() {
504
+ const start = Date.now();
505
+ const {
506
+ target,
507
+ dryRun = false,
508
+ runUploadList = false,
509
+ runDownloadList = false,
510
+ skipSync = false,
511
+ cliLogLevel = null,
512
+ configPath,
513
+ } = this.options;
514
+
515
+ if (!target) {
516
+ console.error(pc.red("❌ No target specified."));
517
+ process.exit(1);
518
+ }
519
+
520
+ const cfgPath = path.resolve(configPath || "sync.config.json");
521
+ if (!fs.existsSync(cfgPath)) {
522
+ console.error(pc.red(`❌ Configuration file missing: ${cfgPath}`));
523
+ process.exit(1);
524
+ }
525
+
526
+ // Config laden
527
+ let configRaw;
528
+ try {
529
+ configRaw = JSON.parse(await fsp.readFile(cfgPath, "utf8"));
530
+ } catch (err) {
531
+ console.error(
532
+ pc.red("❌ Error reading sync.config.json:"),
533
+ err?.message || err
534
+ );
535
+ process.exit(1);
536
+ }
537
+
538
+ if (!configRaw.connections || typeof configRaw.connections !== "object") {
539
+ console.error(
540
+ pc.red("❌ sync.config.json must have a 'connections' field.")
541
+ );
542
+ process.exit(1);
543
+ }
544
+
545
+ const targetConfig = configRaw.connections[target];
546
+ if (!targetConfig) {
547
+ console.error(
548
+ pc.red(`❌ Connection '${target}' not found in sync.config.json.`)
549
+ );
550
+ process.exit(1);
551
+ }
552
+
553
+ const syncCfg = targetConfig.sync ?? targetConfig;
554
+ const sidecarCfg = targetConfig.sidecar ?? {};
555
+
556
+ if (!syncCfg.localRoot || !syncCfg.remoteRoot) {
557
+ console.error(
558
+ pc.red(
559
+ `❌ Connection '${target}' is missing sync.localRoot or sync.remoteRoot.`
560
+ )
561
+ );
562
+ process.exit(1);
563
+ }
564
+
565
+ this.configRaw = configRaw;
566
+ this.targetConfig = targetConfig;
567
+ this.connection = {
568
+ host: targetConfig.host,
569
+ port: targetConfig.port ?? 22,
570
+ user: targetConfig.user,
571
+ password: targetConfig.password,
572
+ localRoot: path.resolve(syncCfg.localRoot),
573
+ remoteRoot: syncCfg.remoteRoot,
574
+ sidecarLocalRoot: path.resolve(sidecarCfg.localRoot ?? syncCfg.localRoot),
575
+ sidecarRemoteRoot: sidecarCfg.remoteRoot ?? syncCfg.remoteRoot,
576
+ workers: targetConfig.worker ?? 2,
577
+ };
578
+
579
+ // LogLevel
580
+ let logLevel = (configRaw.logLevel ?? "normal").toLowerCase();
581
+ if (cliLogLevel) logLevel = cliLogLevel;
582
+ this.logLevel = logLevel;
583
+ this.isVerbose = logLevel === "verbose";
584
+ this.isLaconic = logLevel === "laconic";
585
+
586
+ // Progress-Konfig
587
+ const PROGRESS = configRaw.progress ?? {};
588
+ this.scanChunk = PROGRESS.scanChunk ?? (this.isVerbose ? 1 : 100);
589
+ this.analyzeChunk = PROGRESS.analyzeChunk ?? (this.isVerbose ? 1 : 10);
590
+ this.parallelScan = PROGRESS.parallelScan ?? true;
591
+
592
+ this.cleanupEmptyDirsEnabled = configRaw.cleanupEmptyDirs ?? true;
593
+ this.cleanupEmptyRoots = configRaw.cleanupEmptyRoots ?? false;
594
+
595
+ // Patterns
596
+ this.includePatterns = configRaw.include ?? [];
597
+ this.baseExcludePatterns = configRaw.exclude ?? [];
598
+
599
+ // Dateitypen
600
+ this.textExt =
601
+ configRaw.textExtensions ?? [
602
+ ".html",
603
+ ".htm",
604
+ ".xml",
605
+ ".txt",
606
+ ".json",
607
+ ".js",
608
+ ".mjs",
609
+ ".cjs",
610
+ ".css",
611
+ ".md",
612
+ ".svg",
613
+ ];
614
+
615
+ this.mediaExt =
616
+ configRaw.mediaExtensions ?? [
617
+ ".jpg",
618
+ ".jpeg",
619
+ ".png",
620
+ ".gif",
621
+ ".webp",
622
+ ".avif",
623
+ ".mp4",
624
+ ".mov",
625
+ ".mp3",
626
+ ".wav",
627
+ ".ogg",
628
+ ".flac",
629
+ ".pdf",
630
+ ];
631
+
632
+ const normalizeList = (list) => {
633
+ if (!Array.isArray(list)) return [];
634
+ return list.flatMap((item) =>
635
+ typeof item === "string"
636
+ ? item
637
+ .split(",")
638
+ .map((s) => s.trim())
639
+ .filter(Boolean)
640
+ : []
641
+ );
642
+ };
643
+
644
+ this.uploadList = normalizeList(sidecarCfg.uploadList ?? []);
645
+ this.downloadList = normalizeList(sidecarCfg.downloadList ?? []);
646
+ this.excludePatterns = [
647
+ ...this.baseExcludePatterns,
648
+ ...this.uploadList,
649
+ ...this.downloadList,
650
+ ];
651
+ this.autoExcluded = new Set();
652
+
653
+ // Hash-Cache
654
+ const syncCacheName =
655
+ targetConfig.syncCache || `.sync-cache.${target}.json`;
656
+ const cachePath = path.resolve(syncCacheName);
657
+ this.hashCache = createHashCache({
658
+ cachePath,
659
+ namespace: target,
660
+ flushInterval: 50,
661
+ });
662
+
663
+ // Logger
664
+ const DEFAULT_LOG_FILE = `.sync.${target}.log`;
665
+ const rawLogFilePattern = configRaw.logFile || DEFAULT_LOG_FILE;
666
+ const logFile = path.resolve(
667
+ rawLogFilePattern.replace("{target}", target)
668
+ );
669
+ this.logger = new SyncLogger(logFile);
670
+ await this.logger.init();
671
+
672
+ // Header
673
+ this.log("\n" + hr2());
674
+ this.log(
675
+ pc.bold(
676
+ `🔐 SFTP Push-Synchronisation: sftp-push-sync v${pkg.version}`
677
+ )
678
+ );
679
+ this.log(`${TAB_A}LogLevel: ${this.logLevel}`);
680
+ this.log(`${TAB_A}Connection: ${pc.cyan(target)}`);
681
+ this.log(`${TAB_A}Worker: ${this.connection.workers}`);
682
+ this.log(
683
+ `${TAB_A}Host: ${pc.green(this.connection.host)}:${pc.green(
684
+ this.connection.port
685
+ )}`
686
+ );
687
+ this.log(`${TAB_A}Local: ${pc.green(this.connection.localRoot)}`);
688
+ this.log(`${TAB_A}Remote: ${pc.green(this.connection.remoteRoot)}`);
689
+ if (runUploadList || runDownloadList || skipSync) {
690
+ this.log(
691
+ `${TAB_A}Sidecar Local: ${pc.green(this.connection.sidecarLocalRoot)}`
692
+ );
693
+ this.log(
694
+ `${TAB_A}Sidecar Remote: ${pc.green(
695
+ this.connection.sidecarRemoteRoot
696
+ )}`
697
+ );
698
+ }
699
+ if (dryRun) this.log(pc.yellow(`${TAB_A}Mode: DRY-RUN (no changes)`));
700
+ if (skipSync) this.log(pc.yellow(`${TAB_A}Mode: SKIP-SYNC (bypass only)`));
701
+ if (runUploadList || runDownloadList) {
702
+ this.log(
703
+ pc.blue(
704
+ `${TAB_A}Extra: ${
705
+ runUploadList ? "sidecar-upload " : ""
706
+ }${runDownloadList ? "sidecar-download" : ""}`
707
+ )
708
+ );
709
+ }
710
+ if (this.cleanupEmptyDirsEnabled) {
711
+ this.log(`${TAB_A}Cleanup empty dirs: ${pc.green("enabled")}`);
712
+ }
713
+ if (logFile) {
714
+ this.log(`${TAB_A}LogFile: ${pc.cyan(logFile)}`);
715
+ }
716
+ this.log(hr1());
717
+
718
+ const sftp = new SftpClient();
719
+ let connected = false;
720
+
721
+ let toAdd = [];
722
+ let toUpdate = [];
723
+ let toDelete = [];
724
+
725
+ try {
726
+ this.log("");
727
+ this.log(pc.cyan("🔌 Connecting to SFTP server …"));
728
+ await sftp.connect({
729
+ host: this.connection.host,
730
+ port: this.connection.port,
731
+ username: this.connection.user,
732
+ password: this.connection.password,
733
+ });
734
+ connected = true;
735
+ this.log(`${TAB_A}${pc.green("✔ Connected to SFTP.")}`);
736
+
737
+ if (!skipSync && !fs.existsSync(this.connection.localRoot)) {
738
+ this.elog(
739
+ pc.red("❌ Local root does not exist:"),
740
+ this.connection.localRoot
741
+ );
742
+ process.exit(1);
743
+ }
744
+
745
+ // Bypass-Only?
746
+ if (skipSync) {
747
+ await performSidecarBypass({
748
+ sftp,
749
+ connection: this.connection,
750
+ uploadList: this.uploadList,
751
+ downloadList: this.downloadList,
752
+ options: { dryRun, runUploadList, runDownloadList },
753
+ runTasks: (items, workers, handler, label) =>
754
+ this.runTasks(items, workers, handler, label),
755
+ log: (...m) => this.log(...m),
756
+ vlog: this.isVerbose ? (...m) => this.vlog(...m) : null,
757
+ elog: (...m) => this.elog(...m),
758
+ symbols: { ADD, CHA, tab_a: TAB_A },
759
+ });
760
+
761
+ const duration = ((Date.now() - start) / 1000).toFixed(2);
762
+ this.log("");
763
+ this.log(pc.bold(pc.cyan("📊 Summary (bypass only):")));
764
+ this.log(`${TAB_A}Duration: ${pc.green(duration + " s")}`);
765
+ return;
766
+ }
767
+
768
+ // Phase 1 + 2 – Scan
769
+ this.log("");
770
+ this.log(
771
+ pc.bold(
772
+ pc.cyan(
773
+ `📥 Phase 1 + 2: Scan local & remote files (${
774
+ this.parallelScan ? "parallel" : "serial"
775
+ }) …`
776
+ )
777
+ )
778
+ );
779
+
780
+ const scanProgress = new ScanProgressController({
781
+ writeLogLine: (line) => this._writeLogFile(line),
782
+ });
783
+
784
+ let local;
785
+ let remote;
786
+
787
+ if (this.parallelScan) {
788
+ [local, remote] = await Promise.all([
789
+ walkLocal(this.connection.localRoot, {
790
+ filterFn: (rel) => this.isIncluded(rel),
791
+ classifyFn: (rel) => ({
792
+ isText: this.isTextFile(rel),
793
+ isMedia: this.isMediaFile(rel),
794
+ }),
795
+ progress: scanProgress,
796
+ scanChunk: this.scanChunk,
797
+ log: (msg) => this.log(msg),
798
+ }),
799
+ walkRemote(sftp, this.connection.remoteRoot, {
800
+ filterFn: (rel) => this.isIncluded(rel),
801
+ progress: scanProgress,
802
+ scanChunk: this.scanChunk,
803
+ log: (msg) => this.log(msg),
804
+ }),
805
+ ]);
806
+ } else {
807
+ local = await walkLocal(this.connection.localRoot, {
808
+ filterFn: (rel) => this.isIncluded(rel),
809
+ classifyFn: (rel) => ({
810
+ isText: this.isTextFile(rel),
811
+ isMedia: this.isMediaFile(rel),
812
+ }),
813
+ progress: scanProgress,
814
+ scanChunk: this.scanChunk,
815
+ log: (msg) => this.log(msg),
816
+ });
817
+ remote = await walkRemote(sftp, this.connection.remoteRoot, {
818
+ filterFn: (rel) => this.isIncluded(rel),
819
+ progress: scanProgress,
820
+ scanChunk: this.scanChunk,
821
+ log: (msg) => this.log(msg),
822
+ });
823
+ }
824
+
825
+ scanProgress.stop();
826
+
827
+ this.log(`${TAB_A}→ ${local.size} local files`);
828
+ this.log(`${TAB_A}→ ${remote.size} remote files`);
829
+
830
+ if (this.autoExcluded.size > 0) {
831
+ this.log("");
832
+ this.log(pc.dim(" Auto-excluded (sidecar upload/download):"));
833
+ [...this.autoExcluded].sort().forEach((file) => {
834
+ this.log(pc.dim(`${TAB_A} - ${file}`));
835
+ });
836
+ }
837
+
838
+ this.log("");
839
+
840
+ // Phase 3 – Analyse Differences (delegiert an Helper)
841
+ this.log(pc.bold(pc.cyan("🔎 Phase 3: Compare & decide …")));
842
+
843
+ const { getLocalHash, getRemoteHash, save: saveCache } = this.hashCache;
844
+
845
+ const diffResult = await analyseDifferences({
846
+ local,
847
+ remote,
848
+ remoteRoot: this.connection.remoteRoot,
849
+ sftp,
850
+ getLocalHash,
851
+ getRemoteHash,
852
+ analyzeChunk: this.analyzeChunk,
853
+ updateProgress: (prefix, current, total, rel) =>
854
+ this.updateProgress2(prefix, current, total, rel, "Files"),
855
+ });
856
+
857
+ toAdd = diffResult.toAdd;
858
+ toUpdate = diffResult.toUpdate;
859
+
860
+ if (toAdd.length === 0 && toUpdate.length === 0) {
861
+ this.log("");
862
+ this.log(`${TAB_A}No differences found. Everything is up to date.`);
863
+ } else if (!this.isLaconic) {
864
+ this.log("");
865
+ this.log(pc.bold(pc.cyan("Changes (analysis):")));
866
+ [...toAdd].forEach((t) =>
867
+ this.log(`${TAB_A}${ADD} ${pc.green("New:")} ${t.rel}`)
868
+ );
869
+ [...toUpdate].forEach((t) =>
870
+ this.log(`${TAB_A}${CHA} ${pc.yellow("Changed:")} ${t.rel}`)
871
+ );
872
+ }
873
+
874
+ // Phase 4 – Remote deletes
875
+ this.log("");
876
+ this.log(pc.bold(pc.cyan("🧹 Phase 4: Removing orphaned remote files …")));
877
+
878
+ toDelete = computeRemoteDeletes({ local, remote });
879
+
880
+ if (toDelete.length === 0) {
881
+ this.log(`${TAB_A}No orphaned remote files found.`);
882
+ } else if (!this.isLaconic) {
883
+ toDelete.forEach((t) =>
884
+ this.log(`${TAB_A}${DEL} ${pc.red("Remove:")} ${t.rel}`)
885
+ );
886
+ }
887
+
888
+ // Verzeichnisse vorbereiten
889
+ if (!dryRun && (toAdd.length || toUpdate.length)) {
890
+ this.log("");
891
+ this.log(pc.bold(pc.cyan("📁 Preparing remote directories …")));
892
+ await this.ensureAllRemoteDirsExist(
893
+ sftp,
894
+ this.connection.remoteRoot,
895
+ toAdd,
896
+ toUpdate
897
+ );
898
+ }
899
+
900
+ // Phase 5 – Apply changes
901
+ if (!dryRun) {
902
+ this.log("");
903
+ this.log(pc.bold(pc.cyan("🚚 Phase 5: Apply changes …")));
904
+
905
+ // Upload new files
906
+ await this.runTasks(
907
+ toAdd,
908
+ this.connection.workers,
909
+ async ({ local: l, remotePath }) => {
910
+ const remoteDir = path.posix.dirname(remotePath);
911
+ try {
912
+ await sftp.mkdir(remoteDir, true);
913
+ } catch {
914
+ // Directory may already exist
915
+ }
916
+ await sftp.put(l.localPath, remotePath);
917
+ },
918
+ "Uploads (new)"
919
+ );
920
+
921
+ // Updates
922
+ await this.runTasks(
923
+ toUpdate,
924
+ this.connection.workers,
925
+ async ({ local: l, remotePath }) => {
926
+ const remoteDir = path.posix.dirname(remotePath);
927
+ try {
928
+ await sftp.mkdir(remoteDir, true);
929
+ } catch {
930
+ // Directory may already exist
931
+ }
932
+ await sftp.put(l.localPath, remotePath);
933
+ },
934
+ "Uploads (update)"
935
+ );
936
+
937
+ // Deletes
938
+ await this.runTasks(
939
+ toDelete,
940
+ this.connection.workers,
941
+ async ({ remotePath, rel }) => {
942
+ try {
943
+ await sftp.delete(remotePath);
944
+ } catch (e) {
945
+ this.elog(
946
+ pc.red(" ⚠️ Error during deletion:"),
947
+ rel || remotePath,
948
+ e?.message || e
949
+ );
950
+ }
951
+ },
952
+ "Deletes"
953
+ );
954
+ } else {
955
+ this.log("");
956
+ this.log(
957
+ pc.yellow(
958
+ "💡 DRY-RUN: Connection tested, no files transferred or deleted."
959
+ )
960
+ );
961
+ }
962
+
963
+ // Optional: leere Verzeichnisse aufräumen
964
+ if (!dryRun && this.cleanupEmptyDirsEnabled) {
965
+ this.log("");
966
+ this.log(
967
+ pc.bold(pc.cyan("🧹 Cleaning up empty remote directories …"))
968
+ );
969
+ await this.cleanupEmptyDirs(sftp, this.connection.remoteRoot, dryRun);
970
+ }
971
+
972
+ const duration = ((Date.now() - start) / 1000).toFixed(2);
973
+
974
+ // Cache am Ende sicher schreiben
975
+ await saveCache(true);
976
+
977
+ // Summary
978
+ this.log(hr1());
979
+ this.log("");
980
+ this.log(pc.bold(pc.cyan("📊 Summary:")));
981
+ this.log(`${TAB_A}Duration: ${pc.green(duration + " s")}`);
982
+ this.log(`${TAB_A}${ADD} Added : ${toAdd.length}`);
983
+ this.log(`${TAB_A}${CHA} Changed: ${toUpdate.length}`);
984
+ this.log(`${TAB_A}${DEL} Deleted: ${toDelete.length}`);
985
+ if (this.autoExcluded.size > 0) {
986
+ this.log(
987
+ `${TAB_A}${EXC} Excluded via sidecar upload/download: ${
988
+ this.autoExcluded.size
989
+ }`
990
+ );
991
+ }
992
+
993
+ // Directory-Statistik
994
+ const dirsChecked =
995
+ this.dirStats.ensuredDirs + this.dirStats.cleanupVisited;
996
+ this.log("");
997
+ this.log(pc.bold("Folders:"));
998
+ this.log(`${TAB_A}Checked : ${dirsChecked}`);
999
+ this.log(`${TAB_A}${ADD} Created: ${this.dirStats.createdDirs}`);
1000
+ this.log(`${TAB_A}${DEL} Deleted: ${this.dirStats.cleanupDeleted}`);
1001
+
1002
+ if (toAdd.length || toUpdate.length || toDelete.length) {
1003
+ this.log("");
1004
+ this.log("📄 Changes:");
1005
+ [...toAdd.map((t) => t.rel)]
1006
+ .sort()
1007
+ .forEach((f) => console.log(`${TAB_A}${ADD} ${f}`));
1008
+ [...toUpdate.map((t) => t.rel)]
1009
+ .sort()
1010
+ .forEach((f) => console.log(`${TAB_A}${CHA} ${f}`));
1011
+ [...toDelete.map((t) => t.rel)]
1012
+ .sort()
1013
+ .forEach((f) => console.log(`${TAB_A}${DEL} ${f}`));
1014
+ } else {
1015
+ this.log("");
1016
+ this.log("No changes.");
1017
+ }
1018
+
1019
+ this.log("");
1020
+ this.log(pc.bold(pc.green("✅ Sync complete.")));
1021
+ } catch (err) {
1022
+ const hint = describeSftpError(err);
1023
+ this.elog(pc.red("❌ Synchronisation error:"), err?.message || err);
1024
+ if (hint) {
1025
+ this.wlog(pc.yellow(`${TAB_A}Possible cause:`), hint);
1026
+ }
1027
+ if (this.isVerbose) {
1028
+ console.error(err);
1029
+ }
1030
+ process.exitCode = 1;
1031
+ try {
1032
+ // falls hashCache existiert, Cache noch flushen
1033
+ if (this.hashCache?.save) {
1034
+ await this.hashCache.save(true);
1035
+ }
1036
+ } catch {
1037
+ // ignore
1038
+ }
1039
+ } finally {
1040
+ try {
1041
+ if (connected) {
1042
+ await sftp.end();
1043
+ this.log(pc.green(`${TAB_A}✔ Connection closed.`));
1044
+ }
1045
+ } catch (e) {
1046
+ this.wlog(
1047
+ pc.yellow("⚠️ Could not close SFTP connection cleanly:"),
1048
+ e?.message || e
1049
+ );
1050
+ }
1051
+
1052
+ this.log(hr2());
1053
+ this.log("");
1054
+
1055
+ if (this.logger) {
1056
+ this.logger.close();
1057
+ }
1058
+ }
1059
+ }
1060
+ }