sftp-push-sync 2.1.2 → 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.
@@ -30,1407 +30,170 @@
30
30
  * Delete Folders if
31
31
  * - If, for example, a directory is empty because all files have been deleted from it.
32
32
  * - Or if a directory no longer exists locally.
33
- *
33
+ *
34
34
  * The file sftp-push-sync.mjs is pure JavaScript (ESM), not TypeScript.
35
35
  * Node.js can execute it directly as long as "type": "module" is specified in package.json
36
36
  * or the file has the extension .mjs.
37
37
  */
38
38
  // bin/sftp-push-sync.mjs
39
- import fs from "fs";
40
- import fsp from "fs/promises";
41
- import path from "path";
42
- import SftpClient from "ssh2-sftp-client";
43
- import { minimatch } from "minimatch";
44
- import { diffWords } from "diff";
45
- import { createHash } from "crypto";
46
- import { Writable } from "stream";
47
39
  import pc from "picocolors";
40
+ import { SftpPushSyncApp } from "../src/core/SftpPushSyncApp.mjs";
48
41
 
49
- // get Versionsnummer
50
- import { createRequire } from "module";
51
- const require = createRequire(import.meta.url);
52
- const pkg = require("../package.json");
53
-
54
- // Colors for the State (works on dark + light background)
55
- const ADD = pc.green("+"); // Added
56
- const CHA = pc.yellow("~"); // Changed
57
- const DEL = pc.red("-"); // Deleted
58
- const EXC = pc.redBright("-"); // Excluded
59
-
60
- const hr1 = () => "─".repeat(65); // horizontal line -
61
- const hr2 = () => "=".repeat(65); // horizontal line =
62
- const tab_a = () => " ".repeat(3); // indentation for formatting the terminal output.
63
- const tab_b = () => " ".repeat(6);
64
-
65
- // ---------------------------------------------------------------------------
66
- // CLI arguments
67
42
  // ---------------------------------------------------------------------------
68
-
69
- const args = process.argv.slice(2);
70
- const TARGET = args[0];
71
- const DRY_RUN = args.includes("--dry-run");
72
- const RUN_UPLOAD_LIST = args.includes("--sidecar-upload");
73
- const RUN_DOWNLOAD_LIST = args.includes("--sidecar-download");
74
- const SKIP_SYNC = args.includes("--skip-sync");
75
-
76
- // logLevel override via CLI (optional)
77
- let cliLogLevel = null;
78
- if (args.includes("--verbose")) cliLogLevel = "verbose";
79
- if (args.includes("--laconic")) cliLogLevel = "laconic";
80
-
81
- if (!TARGET) {
82
- console.error(pc.red("❌ Please specify a connection profile:"));
83
- console.error(pc.yellow(`${tab_a()}sftp-push-sync staging --dry-run`));
84
- process.exit(1);
85
- }
86
-
87
- // Wenn jemand --skip-sync ohne Listen benutzt → sinnlos, also abbrechen
88
- if (SKIP_SYNC && !RUN_UPLOAD_LIST && !RUN_DOWNLOAD_LIST) {
89
- console.error(
90
- pc.red(
91
- "❌ --skip-sync requires at least --sidecar-upload or --sidecar-download."
92
- )
93
- );
94
- process.exit(1);
95
- }
96
-
43
+ // CLI-Arguments
97
44
  // ---------------------------------------------------------------------------
98
- // Load config file
45
+ //
46
+ // Call examples:
47
+ //
48
+ // sftp-push-sync staging --dry-run
49
+ // sftp-push-sync live --sidecar-upload --skip-sync
50
+ // sftp-push-sync live --config ./config/sync.live.json
51
+ //
52
+ // Die Struktur:
53
+ // [0] = target
54
+ // [1..] = Flags
99
55
  // ---------------------------------------------------------------------------
100
56
 
101
- const CONFIG_PATH = path.resolve("sync.config.json");
57
+ const rawArgs = process.argv.slice(2);
102
58
 
103
- if (!fs.existsSync(CONFIG_PATH)) {
104
- console.error(pc.red(`❌ Configuration file missing: ${CONFIG_PATH}`));
105
- process.exit(1);
59
+ // Help?
60
+ if (
61
+ rawArgs.length === 0 ||
62
+ rawArgs.includes("--help") ||
63
+ rawArgs.includes("-h")
64
+ ) {
65
+ printUsage();
66
+ process.exit(rawArgs.length === 0 ? 1 : 0);
106
67
  }
107
68
 
108
- let CONFIG_RAW;
109
- try {
110
- CONFIG_RAW = JSON.parse(await fsp.readFile(CONFIG_PATH, "utf8"));
111
- } catch (err) {
112
- console.error(pc.red("❌ Error reading sync.config.json:"), err.message);
113
- process.exit(1);
114
- }
69
+ const TARGET = rawArgs[0];
115
70
 
116
- if (!CONFIG_RAW.connections || typeof CONFIG_RAW.connections !== "object") {
117
- console.error(pc.red("❌ sync.config.json must have a 'connections' field."));
71
+ // If someone only passes flags but no target name
72
+ if (!TARGET || TARGET.startsWith("-")) {
73
+ console.error(pc.red("❌ Please specify a connection profile.\n"));
74
+ printUsage();
118
75
  process.exit(1);
119
76
  }
120
77
 
121
- // ---------------------------------------------------------------------------
122
- // Logging helpers (Terminal + optional Logfile)
123
- // ---------------------------------------------------------------------------
124
-
125
- // Default: .sync.{TARGET}.log, kann via config.logFile überschrieben werden
126
- const DEFAULT_LOG_FILE = `.sync.${TARGET}.log`;
127
- const rawLogFilePattern = CONFIG_RAW.logFile || DEFAULT_LOG_FILE;
128
- const LOG_FILE = path.resolve(rawLogFilePattern.replace("{target}", TARGET));
129
- let LOG_STREAM = null;
130
-
131
- /** einmalig Logfile-Stream öffnen */
132
- function openLogFile() {
133
- if (!LOG_FILE) return;
134
- if (!LOG_STREAM) {
135
- LOG_STREAM = fs.createWriteStream(LOG_FILE, {
136
- flags: "w", // pro Lauf überschreiben
137
- encoding: "utf8",
138
- });
139
- }
140
- }
141
-
142
- /** eine fertige Zeile ins Logfile schreiben (ohne Einfluss auf Terminal) */
143
- function writeLogLine(line) {
144
- if (!LOG_STREAM) return;
145
- // ANSI-Farbsequenzen aus der Log-Zeile entfernen
146
- const clean =
147
- typeof line === "string"
148
- ? line.replace(/\x1b\[[0-9;]*m/g, "")
149
- : String(line).replace(/\x1b\[[0-9;]*m/g, "");
150
- try {
151
- LOG_STREAM.write(clean + "\n");
152
- } catch {
153
- // falls Stream schon zu ist, einfach ignorieren – verhindert ERR_STREAM_WRITE_AFTER_END
154
- }
155
- }
156
-
157
- /** Konsole + Logfile (normal) */
158
- function rawConsoleLog(...msg) {
159
- clearProgressLine();
160
- console.log(...msg);
161
- const line = msg
162
- .map((m) => (typeof m === "string" ? m : String(m)))
163
- .join(" ");
164
- writeLogLine(line);
165
- }
166
-
167
- function rawConsoleError(...msg) {
168
- clearProgressLine();
169
- console.error(...msg);
170
- const line = msg
171
- .map((m) => (typeof m === "string" ? m : String(m)))
172
- .join(" ");
173
- writeLogLine("[ERROR] " + line);
174
- }
175
-
176
- function rawConsoleWarn(...msg) {
177
- clearProgressLine();
178
- console.warn(...msg);
179
- const line = msg
180
- .map((m) => (typeof m === "string" ? m : String(m)))
181
- .join(" ");
182
- writeLogLine("[WARN] " + line);
183
- }
184
-
185
- // High-level Helfer
186
- function log(...msg) {
187
- rawConsoleLog(...msg);
188
- }
189
-
190
- function vlog(...msg) {
191
- if (!IS_VERBOSE) return;
192
- rawConsoleLog(...msg);
193
- }
194
-
195
- function elog(...msg) {
196
- rawConsoleError(...msg);
197
- }
198
-
199
- function wlog(...msg) {
200
- rawConsoleWarn(...msg);
201
- }
202
-
203
- // ---------------------------------------------------------------------------
204
- // Connection
205
- // ---------------------------------------------------------------------------
206
-
207
- const TARGET_CONFIG = CONFIG_RAW.connections[TARGET];
208
- if (!TARGET_CONFIG) {
209
- console.error(
210
- pc.red(`❌ Connection '${TARGET}' not found in sync.config.json.`)
211
- );
212
- process.exit(1);
213
- }
214
-
215
- // Haupt-Sync-Config + Sidecar
216
- const SYNC_CFG = TARGET_CONFIG.sync ?? TARGET_CONFIG;
217
- const SIDECAR_CFG = TARGET_CONFIG.sidecar ?? {};
218
-
219
- if (!SYNC_CFG.localRoot || !SYNC_CFG.remoteRoot) {
78
+ // Evaluate flags from position 1 onwards
79
+ let DRY_RUN = false;
80
+ let RUN_UPLOAD_LIST = false;
81
+ let RUN_DOWNLOAD_LIST = false;
82
+ let SKIP_SYNC = false;
83
+ let cliLogLevel = null;
84
+ let configPath = undefined;
85
+
86
+ const rest = rawArgs.slice(1);
87
+
88
+ for (let i = 0; i < rest.length; i += 1) {
89
+ const a = rest[i];
90
+
91
+ switch (a) {
92
+ case "--dry-run":
93
+ DRY_RUN = true;
94
+ break;
95
+ case "--sidecar-upload":
96
+ RUN_UPLOAD_LIST = true;
97
+ break;
98
+ case "--sidecar-download":
99
+ RUN_DOWNLOAD_LIST = true;
100
+ break;
101
+ case "--skip-sync":
102
+ SKIP_SYNC = true;
103
+ break;
104
+ case "--verbose":
105
+ cliLogLevel = "verbose";
106
+ break;
107
+ case "--laconic":
108
+ cliLogLevel = "laconic";
109
+ break;
110
+ case "--config":
111
+ case "-c": {
112
+ const next = rest[i + 1];
113
+ if (!next || next.startsWith("-")) {
114
+ console.error(
115
+ pc.red("❌ --config expects a path argument (e.g. --config sync.config.json)")
116
+ );
117
+ process.exit(1);
118
+ }
119
+ configPath = next;
120
+ i += 1; // Pfad überspringen
121
+ break;
122
+ }
123
+ default:
124
+ console.error(pc.yellow(`⚠️ Unknown argument ignored: ${a}`));
125
+ break;
126
+ }
127
+ }
128
+
129
+ // --skip-sync without lists → error
130
+ if (SKIP_SYNC && !RUN_UPLOAD_LIST && !RUN_DOWNLOAD_LIST) {
220
131
  console.error(
221
132
  pc.red(
222
- `❌ Connection '${TARGET}' is missing sync.localRoot or sync.remoteRoot.`
133
+ "❌ --skip-sync requires at least --sidecar-upload or --sidecar-download."
223
134
  )
224
135
  );
225
136
  process.exit(1);
226
137
  }
227
138
 
228
- const CONNECTION = {
229
- host: TARGET_CONFIG.host,
230
- port: TARGET_CONFIG.port ?? 22,
231
- user: TARGET_CONFIG.user,
232
- password: TARGET_CONFIG.password,
233
- // Main sync roots
234
- localRoot: path.resolve(SYNC_CFG.localRoot),
235
- remoteRoot: SYNC_CFG.remoteRoot,
236
- // Sidecar roots (für sidecar-upload / sidecar-download)
237
- sidecarLocalRoot: path.resolve(SIDECAR_CFG.localRoot ?? SYNC_CFG.localRoot),
238
- sidecarRemoteRoot: SIDECAR_CFG.remoteRoot ?? SYNC_CFG.remoteRoot,
239
- workers: TARGET_CONFIG.worker ?? 2,
240
- };
241
-
242
- // ---------------------------------------------------------------------------
243
- // LogLevel + Progress aus Config
244
- // ---------------------------------------------------------------------------
245
-
246
- // logLevel: "verbose", "normal", "laconic"
247
- let LOG_LEVEL = (CONFIG_RAW.logLevel ?? "normal").toLowerCase();
248
-
249
- // Override config with CLI flags
250
- if (cliLogLevel) {
251
- LOG_LEVEL = cliLogLevel;
252
- }
253
-
254
- const IS_VERBOSE = LOG_LEVEL === "verbose";
255
- const IS_LACONIC = LOG_LEVEL === "laconic";
256
-
257
- const PROGRESS = CONFIG_RAW.progress ?? {};
258
- const SCAN_CHUNK = PROGRESS.scanChunk ?? (IS_VERBOSE ? 1 : 100);
259
- const ANALYZE_CHUNK = PROGRESS.analyzeChunk ?? (IS_VERBOSE ? 1 : 10);
260
- // For >100k files, rather 10–50, for debugging/troubleshooting 1.
261
-
262
- // Leere Verzeichnisse nach dem Sync entfernen?
263
- const CLEANUP_EMPTY_DIRS = CONFIG_RAW.cleanupEmptyDirs ?? true;
264
- const CLEANUP_EMPTY_ROOTS = CONFIG_RAW.cleanupEmptyRoots ?? false;
265
-
266
139
  // ---------------------------------------------------------------------------
267
- // Shared config from JSON
140
+ // Usage
268
141
  // ---------------------------------------------------------------------------
269
142
 
270
- const INCLUDE = CONFIG_RAW.include ?? [];
271
- const BASE_EXCLUDE = CONFIG_RAW.exclude ?? [];
272
-
273
- // textExtensions
274
- const TEXT_EXT = CONFIG_RAW.textExtensions ?? [
275
- ".html",
276
- ".htm",
277
- ".xml",
278
- ".txt",
279
- ".json",
280
- ".js",
281
- ".mjs",
282
- ".cjs",
283
- ".css",
284
- ".md",
285
- ".svg",
286
- ];
287
-
288
- // mediaExtensions – aktuell nur Meta, aber schon konfigurierbar
289
- const MEDIA_EXT = CONFIG_RAW.mediaExtensions ?? [
290
- ".jpg",
291
- ".jpeg",
292
- ".png",
293
- ".gif",
294
- ".webp",
295
- ".avif",
296
- ".mp4",
297
- ".mov",
298
- ".mp3",
299
- ".wav",
300
- ".ogg",
301
- ".flac",
302
- ".pdf",
303
- ];
304
-
305
- // Special: Lists for targeted uploads/downloads (per-connection sidecar)
306
- function normalizeList(list) {
307
- if (!Array.isArray(list)) return [];
308
- return list.flatMap((item) =>
309
- typeof item === "string"
310
- ? item
311
- .split(",")
312
- .map((s) => s.trim())
313
- .filter(Boolean)
314
- : []
143
+ function printUsage() {
144
+ /* eslint-disable no-console */
145
+ console.log("");
146
+ console.log(pc.bold("Usage:"));
147
+ console.log(" sftp-push-sync <target> [options]");
148
+ console.log("");
149
+ console.log(pc.bold("Examples:"));
150
+ console.log(" sftp-push-sync staging --dry-run");
151
+ console.log(" sftp-push-sync live --sidecar-upload --skip-sync");
152
+ console.log(" sftp-push-sync live --config ./sync.config.live.json");
153
+ console.log("");
154
+ console.log(pc.bold("Options:"));
155
+ console.log(" --dry-run Do not change anything, just simulate");
156
+ console.log(
157
+ " --sidecar-upload Run sidecar upload list (from sync.config.json)"
315
158
  );
316
- }
317
-
318
- // Lists from sidecar config (relative to sidecar.localRoot / sidecar.remoteRoot)
319
- const UPLOAD_LIST = normalizeList(SIDECAR_CFG.uploadList ?? []);
320
- const DOWNLOAD_LIST = normalizeList(SIDECAR_CFG.downloadList ?? []);
321
-
322
- // Effektive Exclude-Liste: explizites exclude + Upload/Download-Listen
323
- // → diese Dateien werden im „normalen“ Sync nicht angerührt,
324
- // sondern nur über die Sidecar-Mechanik behandelt.
325
- const EXCLUDE = [...BASE_EXCLUDE, ...UPLOAD_LIST, ...DOWNLOAD_LIST];
326
-
327
- // List of ALL files that were ausgeschlossen durch uploadList/downloadList
328
- const AUTO_EXCLUDED = new Set();
329
-
330
- // Cache file name per connection
331
- const syncCacheName = TARGET_CONFIG.syncCache || `.sync-cache.${TARGET}.json`;
332
- const CACHE_PATH = path.resolve(syncCacheName);
333
-
334
- // ---------------------------------------------------------------------------
335
- // Load/initialise hash cache
336
- // ---------------------------------------------------------------------------
337
-
338
- let CACHE = {
339
- version: 1,
340
- local: {}, // key: "<TARGET>:<relPath>" -> { size, mtimeMs, hash }
341
- remote: {}, // key: "<TARGET>:<relPath>" -> { size, modifyTime, hash }
342
- };
343
-
344
- try {
345
- if (fs.existsSync(CACHE_PATH)) {
346
- const raw = JSON.parse(await fsp.readFile(CACHE_PATH, "utf8"));
347
- CACHE.version = raw.version ?? 1;
348
- CACHE.local = raw.local ?? {};
349
- CACHE.remote = raw.remote ?? {};
350
- }
351
- } catch (err) {
352
- console.warn(
353
- pc.yellow("⚠️ Could not load cache, starting without:"),
354
- err.message
159
+ console.log(
160
+ " --sidecar-download Run sidecar download list (from sync.config.json)"
355
161
  );
356
- }
357
-
358
- function cacheKey(relPath) {
359
- return `${TARGET}:${relPath}`;
360
- }
361
-
362
- let cacheDirty = false;
363
- let cacheDirtyCount = 0;
364
- const CACHE_FLUSH_INTERVAL = 50; // Write cache to disk after 50 new hashes
365
-
366
- async function saveCache(force = false) {
367
- if (!cacheDirty && !force) return;
368
- const data = JSON.stringify(CACHE, null, 2);
369
- await fsp.writeFile(CACHE_PATH, data, "utf8");
370
- cacheDirty = false;
371
- cacheDirtyCount = 0;
372
- }
373
-
374
- async function markCacheDirty() {
375
- cacheDirty = true;
376
- cacheDirtyCount += 1;
377
- if (cacheDirtyCount >= CACHE_FLUSH_INTERVAL) {
378
- await saveCache();
379
- }
380
- }
381
-
382
- // ---------------------------------------------------------------------------
383
- // Helpers
384
- // ---------------------------------------------------------------------------
385
-
386
- let progressActive = false;
387
-
388
- function clearProgressLine() {
389
- if (!process.stdout.isTTY || !progressActive) return;
390
-
391
- // Zwei Progress-Zeilen ohne zusätzliche Newlines leeren:
392
- // Cursor steht nach updateProgress2() auf der ersten Zeile.
393
- process.stdout.write("\r"); // an Zeilenanfang
394
- process.stdout.write("\x1b[2K"); // erste Zeile löschen
395
- process.stdout.write("\x1b[1B"); // eine Zeile nach unten
396
- process.stdout.write("\x1b[2K"); // zweite Zeile löschen
397
- process.stdout.write("\x1b[1A"); // wieder nach oben
398
-
399
- progressActive = false;
400
- }
401
-
402
- function toPosix(p) {
403
- return p.split(path.sep).join("/");
404
- }
405
-
406
- function matchesAny(patterns, relPath) {
407
- if (!patterns || patterns.length === 0) return false;
408
- return patterns.some((pattern) => minimatch(relPath, pattern, { dot: true }));
409
- }
410
-
411
- function isIncluded(relPath) {
412
- // Include-Regeln
413
- if (INCLUDE.length > 0 && !matchesAny(INCLUDE, relPath)) return false;
414
- // Exclude-Regeln
415
- if (EXCLUDE.length > 0 && matchesAny(EXCLUDE, relPath)) {
416
- // Falls durch Sidecar-Listen → merken
417
- if (UPLOAD_LIST.includes(relPath) || DOWNLOAD_LIST.includes(relPath)) {
418
- AUTO_EXCLUDED.add(relPath);
419
- }
420
- return false;
421
- }
422
- return true;
423
- }
424
-
425
- function isTextFile(relPath) {
426
- const ext = path.extname(relPath).toLowerCase();
427
- return TEXT_EXT.includes(ext);
428
- }
429
-
430
- function isMediaFile(relPath) {
431
- const ext = path.extname(relPath).toLowerCase();
432
- return MEDIA_EXT.includes(ext);
433
- }
434
-
435
- function shortenPathForProgress(rel) {
436
- if (!rel) return "";
437
- const parts = rel.split("/");
438
- if (parts.length === 1) {
439
- return rel; // nur Dateiname
440
- }
441
- if (parts.length === 2) {
442
- return rel; // schon kurz genug
443
- }
444
-
445
- const last = parts[parts.length - 1];
446
- const prev = parts[parts.length - 2];
447
-
448
- // z.B. …/images/foo.jpg
449
- return `…/${prev}/${last}`;
450
- }
451
-
452
- // Two-line progress bar (for terminal) + 1-line log entry
453
- function updateProgress2(prefix, current, total, rel = "") {
454
- const short = rel ? shortenPathForProgress(rel) : "";
455
-
456
- // Log file: always as a single line with **full** rel path
457
- const base =
458
- total && total > 0
459
- ? `${prefix}${current}/${total} Files`
460
- : `${prefix}${current} Files`;
461
- writeLogLine(`[progress] ${base}${rel ? " – " + rel : ""}`);
462
-
463
- if (!process.stdout.isTTY) {
464
- // Fallback-Terminal
465
- if (total && total > 0) {
466
- const percent = ((current / total) * 100).toFixed(1);
467
- console.log(
468
- `${tab_a()}${prefix}${current}/${total} Files (${percent}%) – ${short}`
469
- );
470
- } else {
471
- console.log(`${tab_a()}${prefix}${current} Files – ${short}`);
472
- }
473
- return;
474
- }
475
-
476
- const width = process.stdout.columns || 80;
477
-
478
- let line1;
479
- if (total && total > 0) {
480
- const percent = ((current / total) * 100).toFixed(1);
481
- line1 = `${tab_a()}${prefix}${current}/${total} Files (${percent}%)`;
482
- } else {
483
- // „unknown total“ / Scanner-Modus
484
- line1 = `${tab_a()}${prefix}${current} Files`;
485
- }
486
-
487
- let line2 = short;
488
-
489
- if (line1.length > width) line1 = line1.slice(0, width - 1);
490
- if (line2.length > width) line2 = line2.slice(0, width - 1);
491
-
492
- // zwei Zeilen überschreiben
493
- process.stdout.write("\r" + line1.padEnd(width) + "\n");
494
- process.stdout.write(line2.padEnd(width));
495
-
496
- // Cursor wieder nach oben (auf die Fortschrittszeile)
497
- process.stdout.write("\x1b[1A");
498
-
499
- progressActive = true;
500
- }
501
-
502
- // Simple worker pool for parallel tasks
503
- async function runTasks(items, workerCount, handler, label = "Tasks") {
504
- if (!items || items.length === 0) return;
505
-
506
- const total = items.length;
507
- let done = 0;
508
- let index = 0;
509
-
510
- async function worker() {
511
- // eslint-disable-next-line no-constant-condition
512
- while (true) {
513
- const i = index;
514
- if (i >= total) break;
515
- index += 1;
516
- const item = items[i];
517
- try {
518
- await handler(item);
519
- } catch (err) {
520
- elog(pc.red(`${tab_a()}⚠️ Error in ${label}:`), err.message || err);
521
- }
522
- done += 1;
523
- if (done === 1 || done % 10 === 0 || done === total) {
524
- updateProgress2(`${label}: `, done, total, item.rel ?? "");
525
- }
526
- }
527
- }
528
-
529
- const workers = [];
530
- const actualWorkers = Math.max(1, Math.min(workerCount, total));
531
- for (let i = 0; i < actualWorkers; i += 1) {
532
- workers.push(worker());
533
- }
534
- await Promise.all(workers);
535
- }
536
-
537
- // ---------------------------------------------------------------------------
538
- // Neue Helper: Verzeichnisse für Uploads/Updates vorbereiten
539
- // ---------------------------------------------------------------------------
540
-
541
- function collectDirsFromChanges(changes) {
542
- const dirs = new Set();
543
-
544
- for (const item of changes) {
545
- const rel = item.rel;
546
- if (!rel) continue;
547
-
548
- const parts = rel.split("/");
549
- if (parts.length <= 1) continue; // Dateien im Root
550
-
551
- let acc = "";
552
- for (let i = 0; i < parts.length - 1; i += 1) {
553
- acc = acc ? `${acc}/${parts[i]}` : parts[i];
554
- dirs.add(acc);
555
- }
556
- }
557
-
558
- // flachere Pfade zuerst, damit Eltern vor Kindern angelegt werden
559
- return [...dirs].sort(
560
- (a, b) => a.split("/").length - b.split("/").length
162
+ console.log(
163
+ " --skip-sync Skip normal sync, only run sidecar upload/download"
561
164
  );
562
- }
563
-
564
- async function ensureAllRemoteDirsExist(sftp, remoteRoot, toAdd, toUpdate) {
565
- const dirs = collectDirsFromChanges([...toAdd, ...toUpdate]);
566
-
567
- for (const relDir of dirs) {
568
- const remoteDir = path.posix.join(remoteRoot, relDir);
569
- try {
570
- await sftp.mkdir(remoteDir, true);
571
- vlog(`${tab_a()}${pc.dim("dir ok:")} ${remoteDir}`);
572
- } catch {
573
- // Directory may already exist / keine Rechte – ignorieren
574
- }
575
- }
576
- }
577
-
578
- // -----------------------------------------------------------
579
- // Cleanup: remove *only truly empty* directories on remote
580
- // -----------------------------------------------------------
581
-
582
- async function cleanupEmptyDirs(sftp, rootDir) {
583
- // Rekursiv prüfen, ob ein Verzeichnis und seine Unterverzeichnisse
584
- // KEINE Dateien enthalten. Nur dann löschen wir es.
585
- async function recurse(dir, depth = 0) {
586
- let hasFile = false;
587
- const subdirs = [];
588
-
589
- let items;
590
- try {
591
- items = await sftp.list(dir);
592
- } catch (e) {
593
- // Falls das Verzeichnis inzwischen weg ist o.ä., brechen wir hier ab.
594
- wlog(
595
- pc.yellow("⚠️ Could not list directory during cleanup:"),
596
- dir,
597
- e.message || e
598
- );
599
- return false;
600
- }
601
-
602
- for (const item of items) {
603
- if (!item.name || item.name === "." || item.name === "..") continue;
604
-
605
- if (item.type === "d") {
606
- subdirs.push(item);
607
- } else {
608
- // Jede Datei (egal ob sie nach INCLUDE/EXCLUDE
609
- // sonst ignoriert würde) verhindert das Löschen.
610
- hasFile = true;
611
- }
612
- }
613
-
614
- // Erst alle Unterverzeichnisse aufräumen (post-order)
615
- let allSubdirsEmpty = true;
616
- for (const sub of subdirs) {
617
- const full = path.posix.join(dir, sub.name);
618
- const subEmpty = await recurse(full, depth + 1);
619
- if (!subEmpty) {
620
- allSubdirsEmpty = false;
621
- }
622
- }
623
-
624
- const isRoot = dir === rootDir;
625
- const isEmpty = !hasFile && allSubdirsEmpty;
626
-
627
- // Root nur löschen, wenn explizit erlaubt
628
- if (isEmpty && (!isRoot || CLEANUP_EMPTY_ROOTS)) {
629
- const rel = toPosix(path.relative(rootDir, dir)) || ".";
630
- if (DRY_RUN) {
631
- log(`${tab_a()}${DEL} (DRY-RUN) Remove empty directory: ${rel}`);
632
- } else {
633
- try {
634
- // Nicht rekursiv: wir löschen nur, wenn unser eigener Check "leer" sagt.
635
- await sftp.rmdir(dir, false);
636
- log(`${tab_a()}${DEL} Removed empty directory: ${rel}`);
637
- } catch (e) {
638
- wlog(
639
- pc.yellow("⚠️ Could not remove directory:"),
640
- dir,
641
- e.message || e
642
- );
643
- // Falls rmdir scheitert, betrachten wir das Verzeichnis als "nicht leer"
644
- return false;
645
- }
646
- }
647
- }
648
-
649
- return isEmpty;
650
- }
651
-
652
- await recurse(rootDir, 0);
653
- }
654
-
655
- // ---------------------------------------------------------------------------
656
- // Local file walker (recursive, all subdirectories)
657
- // ---------------------------------------------------------------------------
658
-
659
- async function walkLocal(root) {
660
- const result = new Map();
661
- let scanned = 0;
662
-
663
- async function recurse(current) {
664
- const entries = await fsp.readdir(current, { withFileTypes: true });
665
- for (const entry of entries) {
666
- const full = path.join(current, entry.name);
667
- if (entry.isDirectory()) {
668
- await recurse(full);
669
- } else if (entry.isFile()) {
670
- const rel = toPosix(path.relative(root, full));
671
-
672
- if (!isIncluded(rel)) continue;
673
-
674
- const stat = await fsp.stat(full);
675
- result.set(rel, {
676
- rel,
677
- localPath: full,
678
- size: stat.size,
679
- mtimeMs: stat.mtimeMs,
680
- isText: isTextFile(rel),
681
- isMedia: isMediaFile(rel),
682
- });
683
-
684
- scanned += 1;
685
- const chunk = IS_VERBOSE ? 1 : SCAN_CHUNK;
686
- if (scanned === 1 || scanned % chunk === 0) {
687
- updateProgress2("Scan local: ", scanned, 0, rel);
688
- }
689
- }
690
- }
691
- }
692
-
693
- await recurse(root);
694
-
695
- if (scanned > 0) {
696
- updateProgress2("Scan local: ", scanned, 0, "fertig");
697
- process.stdout.write("\n");
698
- progressActive = false;
699
- }
700
-
701
- return result;
702
- }
703
-
704
- // Plain walker für Bypass (ignoriert INCLUDE/EXCLUDE)
705
- async function walkLocalPlain(root) {
706
- const result = new Map();
707
-
708
- async function recurse(current) {
709
- const entries = await fsp.readdir(current, { withFileTypes: true });
710
- for (const entry of entries) {
711
- const full = path.join(current, entry.name);
712
- if (entry.isDirectory()) {
713
- await recurse(full);
714
- } else if (entry.isFile()) {
715
- const rel = toPosix(path.relative(root, full));
716
- result.set(rel, {
717
- rel,
718
- localPath: full,
719
- });
720
- }
721
- }
722
- }
723
-
724
- await recurse(root);
725
- return result;
726
- }
727
-
728
- // ---------------------------------------------------------------------------
729
- // Remote walker (recursive, all subdirectories) – respects INCLUDE/EXCLUDE
730
- // ---------------------------------------------------------------------------
731
-
732
- async function walkRemote(sftp, remoteRoot) {
733
- const result = new Map();
734
- let scanned = 0;
735
-
736
- async function recurse(remoteDir, prefix) {
737
- const items = await sftp.list(remoteDir);
738
-
739
- for (const item of items) {
740
- if (!item.name || item.name === "." || item.name === "..") continue;
741
-
742
- const full = path.posix.join(remoteDir, item.name);
743
- const rel = prefix ? `${prefix}/${item.name}` : item.name;
744
-
745
- // Include/Exclude-Regeln auch auf Remote anwenden
746
- if (!isIncluded(rel)) continue;
747
-
748
- if (item.type === "d") {
749
- await recurse(full, rel);
750
- } else {
751
- result.set(rel, {
752
- rel,
753
- remotePath: full,
754
- size: Number(item.size),
755
- modifyTime: item.modifyTime ?? 0,
756
- });
757
-
758
- scanned += 1;
759
- const chunk = IS_VERBOSE ? 1 : SCAN_CHUNK;
760
- if (scanned === 1 || scanned % chunk === 0) {
761
- updateProgress2("Scan remote: ", scanned, 0, rel);
762
- }
763
- }
764
- }
765
- }
766
-
767
- await recurse(remoteRoot, "");
768
-
769
- if (scanned > 0) {
770
- updateProgress2("Scan remote: ", scanned, 0, "fertig");
771
- process.stdout.write("\n");
772
- progressActive = false;
773
- }
774
-
775
- return result;
776
- }
777
-
778
- // Plain walker für Bypass (ignoriert INCLUDE/EXCLUDE)
779
- async function walkRemotePlain(sftp, remoteRoot) {
780
- const result = new Map();
781
-
782
- async function recurse(remoteDir, prefix) {
783
- const items = await sftp.list(remoteDir);
784
-
785
- for (const item of items) {
786
- if (!item.name || item.name === "." || item.name === "..") continue;
787
-
788
- const full = path.posix.join(remoteDir, item.name);
789
- const rel = prefix ? `${prefix}/${item.name}` : item.name;
790
-
791
- if (item.type === "d") {
792
- await recurse(full, rel);
793
- } else {
794
- result.set(rel, {
795
- rel,
796
- remotePath: full,
797
- });
798
- }
799
- }
800
- }
801
-
802
- await recurse(remoteRoot, "");
803
- return result;
804
- }
805
-
806
- // ---------------------------------------------------------------------------
807
- // Hash helper for binaries (streaming, memory-efficient)
808
- // ---------------------------------------------------------------------------
809
-
810
- function hashLocalFile(filePath) {
811
- return new Promise((resolve, reject) => {
812
- const hash = createHash("sha256");
813
- const stream = fs.createReadStream(filePath);
814
- stream.on("error", reject);
815
- stream.on("data", (chunk) => hash.update(chunk));
816
- stream.on("end", () => resolve(hash.digest("hex")));
817
- });
818
- }
819
-
820
- async function hashRemoteFile(sftp, remotePath) {
821
- const hash = createHash("sha256");
822
-
823
- const writable = new Writable({
824
- write(chunk, enc, cb) {
825
- hash.update(chunk);
826
- cb();
827
- },
828
- });
829
-
830
- await sftp.get(remotePath, writable);
831
- return hash.digest("hex");
832
- }
833
-
834
- // Cache-aware Helpers
835
- async function getLocalHash(rel, meta) {
836
- const key = cacheKey(rel);
837
- const cached = CACHE.local[key];
838
- if (
839
- cached &&
840
- cached.size === meta.size &&
841
- cached.mtimeMs === meta.mtimeMs &&
842
- cached.hash
843
- ) {
844
- return cached.hash;
845
- }
846
-
847
- const hash = await hashLocalFile(meta.localPath);
848
- CACHE.local[key] = {
849
- size: meta.size,
850
- mtimeMs: meta.mtimeMs,
851
- hash,
852
- };
853
- await markCacheDirty();
854
- return hash;
855
- }
856
-
857
- async function getRemoteHash(rel, meta, sftp) {
858
- const key = cacheKey(rel);
859
- const cached = CACHE.remote[key];
860
- if (
861
- cached &&
862
- cached.size === meta.size &&
863
- cached.modifyTime === meta.modifyTime &&
864
- cached.hash
865
- ) {
866
- return cached.hash;
867
- }
868
-
869
- const hash = await hashRemoteFile(sftp, meta.remotePath);
870
- CACHE.remote[key] = {
871
- size: meta.size,
872
- modifyTime: meta.modifyTime,
873
- hash,
874
- };
875
- await markCacheDirty();
876
- return hash;
877
- }
878
-
879
- // ---------------------------------------------------------------------------
880
- // SFTP error explanation (for clearer messages)
881
- // ---------------------------------------------------------------------------
882
-
883
- function describeSftpError(err) {
884
- if (!err) return "";
885
-
886
- const code = err.code || err.errno || "";
887
- const msg = (err.message || "").toLowerCase();
888
-
889
- // Netzwerk / DNS
890
- if (code === "ENOTFOUND") {
891
- return "Host not found (ENOTFOUND) – Check hostname or DNS entry.";
892
- }
893
- if (code === "EHOSTUNREACH") {
894
- return "Host not reachable (EHOSTUNREACH) – Check network/firewall.";
895
- }
896
- if (code === "ECONNREFUSED") {
897
- return "Connection refused (ECONNREFUSED) – Check the port or SSH service.";
898
- }
899
- if (code === "ECONNRESET") {
900
- return "Connection was reset by the server (ECONNRESET).";
901
- }
902
- if (code === "ETIMEDOUT") {
903
- return "Connection timeout (ETIMEDOUT) – Server is not responding or is blocked.";
904
- }
905
-
906
- // Auth / Authorisations
907
- if (msg.includes("all configured authentication methods failed")) {
908
- return "Authentication failed – check your username/password or SSH keys.";
909
- }
910
- if (msg.includes("permission denied")) {
911
- return "Access denied – check permissions on the server.";
912
- }
913
-
914
- // Fallback
915
- return "";
916
- }
917
-
918
- // ---------------------------------------------------------------------------
919
- // Bypass-only Mode (sidecar-upload / sidecar-download ohne normalen Sync)
920
- // ---------------------------------------------------------------------------
921
-
922
- async function collectUploadTargets() {
923
- const all = await walkLocalPlain(CONNECTION.sidecarLocalRoot);
924
- const results = [];
925
-
926
- for (const [rel, meta] of all.entries()) {
927
- if (matchesAny(UPLOAD_LIST, rel)) {
928
- const remotePath = path.posix.join(CONNECTION.sidecarRemoteRoot, rel);
929
- results.push({
930
- rel,
931
- localPath: meta.localPath,
932
- remotePath,
933
- });
934
- }
935
- }
936
-
937
- return results;
938
- }
939
-
940
- async function collectDownloadTargets(sftp) {
941
- const all = await walkRemotePlain(sftp, CONNECTION.sidecarRemoteRoot);
942
- const results = [];
943
-
944
- for (const [rel, meta] of all.entries()) {
945
- if (matchesAny(DOWNLOAD_LIST, rel)) {
946
- const localPath = path.join(CONNECTION.sidecarLocalRoot, rel);
947
- results.push({
948
- rel,
949
- remotePath: meta.remotePath,
950
- localPath,
951
- });
952
- }
953
- }
954
-
955
- return results;
956
- }
957
-
958
- async function performBypassOnly(sftp) {
959
- log("");
960
- log(pc.bold(pc.cyan("🚀 Bypass-Only Mode (skip-sync)")));
961
- log(`${tab_a()}Sidecar Local: ${pc.green(CONNECTION.sidecarLocalRoot)}`);
962
- log(`${tab_a()}Sidecar Remote: ${pc.green(CONNECTION.sidecarRemoteRoot)}`);
963
-
964
- if (RUN_UPLOAD_LIST && !fs.existsSync(CONNECTION.sidecarLocalRoot)) {
965
- elog(
966
- pc.red("❌ Sidecar local root does not exist:"),
967
- CONNECTION.sidecarLocalRoot
968
- );
969
- process.exit(1);
970
- }
971
-
972
- if (RUN_UPLOAD_LIST) {
973
- log("");
974
- log(pc.bold(pc.cyan("⬆️ Upload-Bypass (sidecar-upload) …")));
975
- const targets = await collectUploadTargets();
976
- log(`${tab_a()}→ ${targets.length} files from uploadList`);
977
-
978
- if (!DRY_RUN) {
979
- await runTasks(
980
- targets,
981
- CONNECTION.workers,
982
- async ({ localPath, remotePath, rel }) => {
983
- const remoteDir = path.posix.dirname(remotePath);
984
- try {
985
- await sftp.mkdir(remoteDir, true);
986
- } catch {
987
- // Directory may already exist
988
- }
989
- await sftp.put(localPath, remotePath);
990
- vlog(`${tab_a()}${ADD} Uploaded (bypass): ${rel}`);
991
- },
992
- "Bypass Uploads"
993
- );
994
- } else {
995
- for (const t of targets) {
996
- log(`${tab_a()}${ADD} (DRY-RUN) Upload: ${t.rel}`);
997
- }
998
- }
999
- }
1000
-
1001
- if (RUN_DOWNLOAD_LIST) {
1002
- log("");
1003
- log(pc.bold(pc.cyan("⬇️ Download-Bypass (sidecar-download) …")));
1004
- const targets = await collectDownloadTargets(sftp);
1005
- log(`${tab_a()}→ ${targets.length} files from downloadList`);
1006
-
1007
- if (!DRY_RUN) {
1008
- await runTasks(
1009
- targets,
1010
- CONNECTION.workers,
1011
- async ({ remotePath, localPath, rel }) => {
1012
- const localDir = path.dirname(localPath);
1013
- await fsp.mkdir(localDir, { recursive: true });
1014
- await sftp.get(remotePath, localPath);
1015
- vlog(`${tab_a()}${CHA} Downloaded (bypass): ${rel}`);
1016
- },
1017
- "Bypass Downloads"
1018
- );
1019
- } else {
1020
- for (const t of targets) {
1021
- log(`${tab_a()}${CHA} (DRY-RUN) Download: ${t.rel}`);
1022
- }
1023
- }
1024
- }
1025
-
1026
- log("");
1027
- log(pc.bold(pc.green("✅ Bypass-only run finished.")));
165
+ console.log(" --verbose Enable verbose logging");
166
+ console.log(" --laconic Minimal logging (overrides verbose)");
167
+ console.log(
168
+ " --config, -c <file> Use custom config file (default: ./sync.config.json)"
169
+ );
170
+ console.log(" --help, -h Show this help");
171
+ console.log("");
172
+ /* eslint-enable no-console */
1028
173
  }
1029
174
 
1030
175
  // ---------------------------------------------------------------------------
1031
176
  // MAIN
1032
177
  // ---------------------------------------------------------------------------
1033
178
 
1034
- async function initLogFile() {
1035
- if (!LOG_FILE) return;
1036
- const dir = path.dirname(LOG_FILE);
1037
- await fsp.mkdir(dir, { recursive: true });
1038
- LOG_STREAM = fs.createWriteStream(LOG_FILE, {
1039
- flags: "w",
1040
- encoding: "utf8",
1041
- });
1042
- }
1043
-
1044
179
  async function main() {
1045
- const start = Date.now();
1046
-
1047
- await initLogFile();
1048
-
1049
- // Header-Abstand wie gehabt: zwei Leerzeilen davor
1050
- log("\n" + hr2());
1051
- log(pc.bold(`🔐 SFTP Push-Synchronisation: sftp-push-sync v${pkg.version}`));
1052
- log(`${tab_a()}LogLevel: ${LOG_LEVEL}`);
1053
- log(`${tab_a()}Connection: ${pc.cyan(TARGET)}`);
1054
- log(`${tab_a()}Worker: ${CONNECTION.workers}`);
1055
- log(
1056
- `${tab_a()}Host: ${pc.green(CONNECTION.host)}:${pc.green(CONNECTION.port)}`
1057
- );
1058
- log(`${tab_a()}Local: ${pc.green(CONNECTION.localRoot)}`);
1059
- log(`${tab_a()}Remote: ${pc.green(CONNECTION.remoteRoot)}`);
1060
- if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST || SKIP_SYNC) {
1061
- log(`${tab_a()}Sidecar Local: ${pc.green(CONNECTION.sidecarLocalRoot)}`);
1062
- log(`${tab_a()}Sidecar Remote: ${pc.green(CONNECTION.sidecarRemoteRoot)}`);
1063
- }
1064
- if (DRY_RUN) log(pc.yellow(`${tab_a()}Mode: DRY-RUN (no changes)`));
1065
- if (SKIP_SYNC) log(pc.yellow(`${tab_a()}Mode: SKIP-SYNC (bypass only)`));
1066
- if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST) {
1067
- log(
1068
- pc.blue(
1069
- `${tab_a()}Extra: ${
1070
- RUN_UPLOAD_LIST ? "sidecar-upload " : ""
1071
- }${RUN_DOWNLOAD_LIST ? "sidecar-download" : ""}`
1072
- )
1073
- );
1074
- }
1075
- if (CLEANUP_EMPTY_DIRS) {
1076
- log(`${tab_a()}Cleanup empty dirs: ${pc.green("enabled")}`);
1077
- }
1078
- if (LOG_FILE) {
1079
- log(`${tab_a()}LogFile: ${pc.cyan(LOG_FILE)}`);
1080
- }
1081
- log(hr1());
1082
-
1083
- const sftp = new SftpClient();
1084
- let connected = false;
1085
-
1086
- const toAdd = [];
1087
- const toUpdate = [];
1088
- const toDelete = [];
1089
-
1090
- try {
1091
- log("");
1092
- log(pc.cyan("🔌 Connecting to SFTP server …"));
1093
- await sftp.connect({
1094
- host: CONNECTION.host,
1095
- port: CONNECTION.port,
1096
- username: CONNECTION.user,
1097
- password: CONNECTION.password,
1098
- });
1099
- connected = true;
1100
- log(pc.green(`${tab_a()}✔ Connected to SFTP.`));
1101
-
1102
- if (!SKIP_SYNC && !fs.existsSync(CONNECTION.localRoot)) {
1103
- console.error(
1104
- pc.red("❌ Local root does not exist:"),
1105
- CONNECTION.localRoot
1106
- );
1107
- process.exit(1);
1108
- }
1109
-
1110
- // -------------------------------------------------------------
1111
- // SKIP-SYNC-Modus → nur Sidecar-Listen
1112
- // -------------------------------------------------------------
1113
- if (SKIP_SYNC) {
1114
- await performBypassOnly(sftp);
1115
- const duration = ((Date.now() - start) / 1000).toFixed(2);
1116
- log("");
1117
- log(pc.bold(pc.cyan("📊 Summary (bypass only):")));
1118
- log(`${tab_a()}Duration: ${pc.green(duration + " s")}`);
1119
- return;
1120
- }
1121
-
1122
- // -------------------------------------------------------------
1123
- // Normaler Sync (inkl. evtl. paralleler Sidecar-Excludes)
1124
- // -------------------------------------------------------------
1125
-
1126
- // Phase 1 – mit exakt einer Leerzeile davor
1127
- log("");
1128
- log(pc.bold(pc.cyan("📥 Phase 1: Scan local files …")));
1129
- const local = await walkLocal(CONNECTION.localRoot);
1130
- log(`${tab_a()}→ ${local.size} local files`);
1131
-
1132
- if (AUTO_EXCLUDED.size > 0) {
1133
- log("");
1134
- log(pc.dim(" Auto-excluded (sidecar upload/download):"));
1135
- [...AUTO_EXCLUDED].sort().forEach((file) => {
1136
- log(pc.dim(`${tab_a()} - ${file}`));
1137
- });
1138
- log("");
1139
- }
1140
-
1141
- // Phase 2 – auch mit einer Leerzeile davor
1142
- log("");
1143
- log(pc.bold(pc.cyan("📤 Phase 2: Scan remote files …")));
1144
- const remote = await walkRemote(sftp, CONNECTION.remoteRoot);
1145
- log(`${tab_a()}→ ${remote.size} remote files`);
1146
- log("");
1147
-
1148
- const localKeys = new Set(local.keys());
1149
- const remoteKeys = new Set(remote.keys());
1150
-
1151
- log(pc.bold(pc.cyan("🔎 Phase 3: Compare & decide …")));
1152
- const totalToCheck = localKeys.size;
1153
- let checkedCount = 0;
1154
-
1155
- // Analysis: just decide, don't upload/delete anything yet
1156
- for (const rel of localKeys) {
1157
- checkedCount += 1;
1158
-
1159
- const chunk = IS_VERBOSE ? 1 : ANALYZE_CHUNK;
1160
- if (
1161
- checkedCount === 1 || // immediate first issue
1162
- checkedCount % chunk === 0 ||
1163
- checkedCount === totalToCheck
1164
- ) {
1165
- updateProgress2("Analyse: ", checkedCount, totalToCheck, rel);
1166
- }
1167
-
1168
- const l = local.get(rel);
1169
- const r = remote.get(rel);
1170
- const remotePath = path.posix.join(CONNECTION.remoteRoot, rel);
1171
-
1172
- if (!r) {
1173
- toAdd.push({ rel, local: l, remotePath });
1174
- if (!IS_LACONIC) {
1175
- log(`${ADD} ${pc.green("New:")} ${rel}`);
1176
- }
1177
- continue;
1178
- }
1179
-
1180
- // 1. size comparison
1181
- if (l.size !== r.size) {
1182
- toUpdate.push({ rel, local: l, remote: r, remotePath });
1183
- if (!IS_LACONIC) {
1184
- log(`${CHA} ${pc.yellow("Size changed:")} ${rel}`);
1185
- }
1186
- continue;
1187
- }
1188
-
1189
- // 2. content comparison
1190
- if (l.isText) {
1191
- // Text file: Read & compare in full
1192
- const [localBuf, remoteBuf] = await Promise.all([
1193
- fsp.readFile(l.localPath),
1194
- sftp.get(r.remotePath),
1195
- ]);
1196
-
1197
- const localStr = localBuf.toString("utf8");
1198
- const remoteStr = (
1199
- Buffer.isBuffer(remoteBuf) ? remoteBuf : Buffer.from(remoteBuf)
1200
- ).toString("utf8");
1201
-
1202
- if (localStr === remoteStr) {
1203
- vlog(`${tab_a()}${pc.dim("✓ Unchanged (Text):")} ${rel}`);
1204
- continue;
1205
- }
1206
-
1207
- if (IS_VERBOSE) {
1208
- const diff = diffWords(remoteStr, localStr);
1209
- const blocks = diff.filter((d) => d.added || d.removed).length;
1210
- vlog(`${tab_a()}${CHA} Text difference (${blocks} blocks) in ${rel}`);
1211
- }
1212
-
1213
- toUpdate.push({ rel, local: l, remote: r, remotePath });
1214
- if (!IS_LACONIC) {
1215
- log(
1216
- `${tab_a()}${CHA} ${pc.yellow("Content changed (Text):")} ${rel}`
1217
- );
1218
- }
1219
- } else {
1220
- // Binary: Hash comparison with cache
1221
- const localMeta = l;
1222
- const remoteMeta = r;
1223
-
1224
- const [localHash, remoteHash] = await Promise.all([
1225
- getLocalHash(rel, localMeta),
1226
- getRemoteHash(rel, remoteMeta, sftp),
1227
- ]);
1228
-
1229
- if (localHash === remoteHash) {
1230
- vlog(`${tab_a()}${pc.dim("✓ Unchanged (binary, hash):")} ${rel}`);
1231
- continue;
1232
- }
1233
-
1234
- if (IS_VERBOSE) {
1235
- vlog(`${tab_a()}${CHA} Hash different (binary): ${rel}`);
1236
- vlog(`${tab_b()}local: ${localHash}`);
1237
- vlog(`${tab_b()}remote: ${remoteHash}`);
1238
- }
1239
-
1240
- toUpdate.push({ rel, local: l, remote: r, remotePath });
1241
- if (!IS_LACONIC) {
1242
- log(`${CHA} ${pc.yellow("Content changed (Binary):")} ${rel}`);
1243
- }
1244
- }
1245
- }
1246
-
1247
- // Wenn Phase 3 nichts gefunden hat, explizit sagen
1248
- if (toAdd.length === 0 && toUpdate.length === 0) {
1249
- log("");
1250
- log(`${tab_a()}No differences found. Everything is up to date.`);
1251
- }
1252
-
1253
- log("");
1254
- log(pc.bold(pc.cyan("🧹 Phase 4: Removing orphaned remote files …")));
1255
- for (const rel of remoteKeys) {
1256
- if (!localKeys.has(rel)) {
1257
- const r = remote.get(rel);
1258
- toDelete.push({ rel, remotePath: r.remotePath });
1259
- if (!IS_LACONIC) {
1260
- log(`${tab_a()}${DEL} ${pc.red("Remove:")} ${rel}`);
1261
- }
1262
- }
1263
- }
1264
-
1265
- // Auch für Phase 4 eine „nix zu tun“-Meldung
1266
- if (toDelete.length === 0) {
1267
- log(`${tab_a()}No orphaned remote files found.`);
1268
- }
1269
-
1270
- // -------------------------------------------------------------------
1271
- // Verzeichnisse vorab anlegen (damit Worker sich nicht ins Gehege kommen)
1272
- // -------------------------------------------------------------------
1273
- if (!DRY_RUN && (toAdd.length || toUpdate.length)) {
1274
- log("");
1275
- log(pc.bold(pc.cyan("📁 Preparing remote directories …")));
1276
- await ensureAllRemoteDirsExist(
1277
- sftp,
1278
- CONNECTION.remoteRoot,
1279
- toAdd,
1280
- toUpdate
1281
- );
1282
- }
1283
-
1284
- // -------------------------------------------------------------------
1285
- // Phase 5: Execute changes (parallel, worker-based)
1286
- // -------------------------------------------------------------------
1287
-
1288
- if (!DRY_RUN) {
1289
- log("");
1290
- log(pc.bold(pc.cyan("🚚 Phase 5: Apply changes …")));
1291
-
1292
- // Upload new files
1293
- await runTasks(
1294
- toAdd,
1295
- CONNECTION.workers,
1296
- async ({ local: l, remotePath }) => {
1297
- // Verzeichnisse sollten bereits existieren – mkdir hier nur als Fallback
1298
- const remoteDir = path.posix.dirname(remotePath);
1299
- try {
1300
- await sftp.mkdir(remoteDir, true);
1301
- } catch {
1302
- // Directory may already exist.
1303
- }
1304
- await sftp.put(l.localPath, remotePath);
1305
- },
1306
- "Uploads (new)"
1307
- );
1308
-
1309
- // Updates
1310
- await runTasks(
1311
- toUpdate,
1312
- CONNECTION.workers,
1313
- async ({ local: l, remotePath }) => {
1314
- const remoteDir = path.posix.dirname(remotePath);
1315
- try {
1316
- await sftp.mkdir(remoteDir, true);
1317
- } catch {
1318
- // Directory may already exist.
1319
- }
1320
- await sftp.put(l.localPath, remotePath);
1321
- },
1322
- "Uploads (update)"
1323
- );
1324
-
1325
- // Deletes
1326
- await runTasks(
1327
- toDelete,
1328
- CONNECTION.workers,
1329
- async ({ remotePath }) => {
1330
- try {
1331
- await sftp.delete(remotePath);
1332
- } catch (e) {
1333
- console.error(
1334
- pc.red(" ⚠️ Error during deletion:"),
1335
- remotePath,
1336
- e.message || e
1337
- );
1338
- }
1339
- },
1340
- "Deletes"
1341
- );
1342
- } else {
1343
- log("");
1344
- log(
1345
- pc.yellow(
1346
- "💡 DRY-RUN: Connection tested, no files transferred or deleted."
1347
- )
1348
- );
1349
- }
1350
-
1351
- // Optional: leere Verzeichnisse aufräumen
1352
- if (!DRY_RUN && CLEANUP_EMPTY_DIRS) {
1353
- log("");
1354
- log(pc.bold(pc.cyan("🧹 Cleaning up empty remote directories …")));
1355
- await cleanupEmptyDirs(sftp, CONNECTION.remoteRoot);
1356
- }
1357
-
1358
- const duration = ((Date.now() - start) / 1000).toFixed(2);
1359
-
1360
- // Write cache safely at the end
1361
- await saveCache(true);
1362
-
1363
- // Summary
1364
- log(hr1());
1365
- log("");
1366
- log(pc.bold(pc.cyan("📊 Summary:")));
1367
- log(`${tab_a()}Duration: ${pc.green(duration + " s")}`);
1368
- log(`${tab_a()}${ADD} Added : ${toAdd.length}`);
1369
- log(`${tab_a()}${CHA} Changed: ${toUpdate.length}`);
1370
- log(`${tab_a()}${DEL} Deleted: ${toDelete.length}`);
1371
- if (AUTO_EXCLUDED.size > 0) {
1372
- log(
1373
- `${tab_a()}${EXC} Excluded via sidecar upload/download: ${
1374
- AUTO_EXCLUDED.size
1375
- }`
1376
- );
1377
- }
1378
- if (toAdd.length || toUpdate.length || toDelete.length) {
1379
- log("");
1380
- log("📄 Changes:");
1381
- [...toAdd.map((t) => t.rel)]
1382
- .sort()
1383
- .forEach((f) => console.log(`${tab_a()}${ADD} ${f}`));
1384
- [...toUpdate.map((t) => t.rel)]
1385
- .sort()
1386
- .forEach((f) => console.log(`${tab_a()}${CHA} ${f}`));
1387
- [...toDelete.map((t) => t.rel)]
1388
- .sort()
1389
- .forEach((f) => console.log(`${tab_a()}${DEL} ${f}`));
1390
- } else {
1391
- log("");
1392
- log("No changes.");
1393
- }
1394
-
1395
- log("");
1396
- log(pc.bold(pc.green("✅ Sync complete.")));
1397
- } catch (err) {
1398
- const hint = describeSftpError(err);
1399
- elog(pc.red("❌ Synchronisation error:"), err.message || err);
1400
- if (hint) {
1401
- wlog(pc.yellow(`${tab_a()}Mögliche Ursache:`), hint);
1402
- }
1403
- if (IS_VERBOSE) {
1404
- // Vollständiges Error-Objekt nur in verbose anzeigen
1405
- console.error(err);
1406
- }
1407
- process.exitCode = 1;
1408
- try {
1409
- await saveCache(true);
1410
- } catch {
1411
- // ignore
1412
- }
1413
- } finally {
1414
- try {
1415
- if (connected) {
1416
- await sftp.end();
1417
- log(pc.green(`${tab_a()}✔ Connection closed.`));
1418
- }
1419
- } catch (e) {
1420
- wlog(
1421
- pc.yellow("⚠️ Could not close SFTP connection cleanly:"),
1422
- e.message || e
1423
- );
1424
- }
1425
-
1426
- // Abschlusslinie + Leerzeile **vor** dem Schließen des Logfiles
1427
- log(hr2());
1428
- log("");
180
+ const app = new SftpPushSyncApp({
181
+ target: TARGET,
182
+ dryRun: DRY_RUN,
183
+ runUploadList: RUN_UPLOAD_LIST,
184
+ runDownloadList: RUN_DOWNLOAD_LIST,
185
+ skipSync: SKIP_SYNC,
186
+ cliLogLevel,
187
+ configPath,
188
+ });
1429
189
 
1430
- if (LOG_STREAM) {
1431
- LOG_STREAM.end();
1432
- }
1433
- }
190
+ await app.run();
1434
191
  }
1435
192
 
1436
- main();
193
+ main().catch((err) => {
194
+ console.error(pc.red("❌ Unhandled error in sftp-push-sync:"), err?.message || err);
195
+ if (process.env.DEBUG) {
196
+ console.error(err);
197
+ }
198
+ process.exit(1);
199
+ });