sftp-push-sync 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,26 +65,66 @@ Create a `sync.config.json` in the root folder of your project:
65
65
  },
66
66
  "include": [],
67
67
  "exclude": ["**/.DS_Store", "**/.git/**", "**/node_modules/**"],
68
- "textExtensions": [
69
- ".html",
70
- ".xml",
71
- ".txt",
72
- ".json",
73
- ".js",
74
- ".css",
75
- ".md",
76
- ".svg"
77
- ],
68
+ "textExtensions": [".html",".xml",".txt",".json",".js",".css",".md",".svg"],
69
+ "mediaExtensions": [".jpg",".jpeg",".png",".webp",".gif",".avif",".tif",".tiff",".mp4",".mov",".m4v","mp3",".wav",".flac"],
78
70
  "progress": {
79
71
  "scanChunk": 10,
80
72
  "analyzeChunk": 1
81
73
  },
82
74
  "logLevel": "normal",
75
+ "logFile": ".sftp-push-sync.{target}.log",
83
76
  "uploadList": [],
84
77
  "downloadList": ["download-counter.json"]
85
78
  }
86
79
  ```
87
80
 
81
+ ### CLI Usage
82
+
83
+ ```bash
84
+ # Normal synchronisation
85
+ node bin/sftp-push-sync.mjs staging
86
+
87
+ # Consider normal synchronisation + upload list
88
+ node bin/sftp-push-sync.mjs staging --upload-list
89
+
90
+ # Only lists, no standard synchronisation
91
+ node bin/sftp-push-sync.mjs staging --skip-sync --upload-list
92
+ node bin/sftp-push-sync.mjs staging --skip-sync --download-list
93
+ node bin/sftp-push-sync.mjs staging --skip-sync --upload-list --download-list
94
+
95
+ # (optional) only run lists dry
96
+ node bin/sftp-push-sync.mjs staging --skip-sync --upload-list --dry-run
97
+ ```
98
+
99
+ - Can be conveniently started via the scripts in `package.json`:
100
+
101
+ ```bash
102
+ # For example
103
+ npm run sync:staging
104
+ # or short
105
+ npm run ss
106
+ ```
107
+
108
+ If you have stored the scripts in `package.json` as follows:
109
+
110
+ ```json
111
+
112
+ "scripts": {
113
+ "sync:staging": "sftp-push-sync staging",
114
+ "sync:staging:dry": "sftp-push-sync staging --dry-run",
115
+ "ss": "npm run sync:staging",
116
+ "ssd": "npm run sync:staging:dry",
117
+
118
+ "sync:prod": "sftp-push-sync prod",
119
+ "sync:prod:dry": "sftp-push-sync prod --dry-run",
120
+ "sp": "npm run sync:prod",
121
+ "spd": "npm run sync:prod:dry",
122
+ },
123
+ ```
124
+
125
+ The dry run is a great way to compare files and fill the cache.
126
+
127
+
88
128
  ### special uploads / downloads
89
129
 
90
130
  A list of files that are excluded from the sync comparison and can be downloaded or uploaded separately.
@@ -113,41 +153,45 @@ sftp-push-sync prod --download-list # then do
113
153
  Logging can also be configured.
114
154
 
115
155
  - `logLevel` - normal, verbose, laconic.
156
+ - `logFile` - an optional logFile.
116
157
  - `scanChunk` - After how many elements should a log output be generated during scanning?
117
158
  - `analyzeChunk` - After how many elements should a log output be generated during analysis?
118
159
 
119
160
  For >100k files, use analyzeChunk = 10 or 50, otherwise the TTY output itself is a relevant factor.
120
161
 
121
- ## NPM Scripts
122
-
123
- - Can be conveniently started via the scripts in `package.json`:
124
-
125
- ```bash
126
- # For example
127
- npm run sync:staging
128
- # or short
129
- npm run ss
162
+ ### Wildcards
163
+
164
+ Examples for Wirdcards for `include`, `exclude`, `uploadList` and `downloadList`:
165
+
166
+ - `"content/**"` - ALLES unterhalb von `content/`
167
+ - `".html", ".htm", ".md", ".txt", ".json"`- Nur bestimmte Dateiendungen
168
+ - `"**/*.html"` - alle HTML-Dateien
169
+ - `"**/*.md"`- alle Markdown-Dateien
170
+ - `"content/**/*.md"` - nur Markdown in `content/`
171
+ - `"static/images/**/*.jpg"`
172
+ - `"**/thumb-*.*"` - thumb-Bilder überall
173
+ - `"**/*-draft.*"` - Dateien mit -draft vor der Extension
174
+ - `"content/**/*.md"` - alle Markdown-Dateien
175
+ - `"config/**"` - komplette Konfiguration
176
+ - `"static/images/covers/**"`- nur Cover-Bilder
177
+ - `"logs/**/*.log"` - alle Logs aus logs/
178
+ - `"reports/**/*.xlsx"`
179
+
180
+ practical excludes:
181
+
182
+ ```txt
183
+ "exclude": [
184
+ ".git/**", // kompletter .git Ordner
185
+ ".idea/**", // JetBrains
186
+ "node_modules/**", // Node dependencies
187
+ "dist/**", // Build Output
188
+ "**/*.map", // Source Maps
189
+ "**/~*", // Emacs/Editor-Backups (~Dateien)
190
+ "**/#*#", // weitere Editor-Backups
191
+ "**/.DS_Store" // macOS Trash
192
+ ]
130
193
  ```
131
194
 
132
- If you have stored the scripts in `package.json` as follows:
133
-
134
- ```json
135
-
136
- "scripts": {
137
- "sync:staging": "sftp-push-sync staging",
138
- "sync:staging:dry": "sftp-push-sync staging --dry-run",
139
- "ss": "npm run sync:staging",
140
- "ssd": "npm run sync:staging:dry",
141
-
142
- "sync:prod": "sftp-push-sync prod",
143
- "sync:prod:dry": "sftp-push-sync prod --dry-run",
144
- "sp": "npm run sync:prod",
145
- "spd": "npm run sync:prod:dry",
146
- },
147
- ```
148
-
149
- The dry run is a great way to compare files and fill the cache.
150
-
151
195
  ## Which files are needed?
152
196
 
153
197
  - `sync.config.json` - The configuration file (with passwords in plain text, so please leave it out of the git repository)
@@ -155,10 +199,11 @@ The dry run is a great way to compare files and fill the cache.
155
199
  ## Which files are created?
156
200
 
157
201
  - The cache files: `.sync-cache.*.json`
202
+ - The log file: `.sftp-push-sync.{target}.log` (Optional, overwritten with each run)
158
203
 
159
- You can safely delete the local cache at any time. The first analysis will then take longer again (because remote hashes will be streamed again). After that, everything will run fast.
204
+ You can safely delete the local cache at any time. The first analysis will then take longer again, because remote hashes will be streamed again. After that, everything will run fast.
160
205
 
161
- The first run always takes a while, especially with lots of images – so be patient! Once the cache is full, it will be faster.
206
+ Note: The first run always takes a while, especially with lots of images – so be patient! Once the cache is full, it will be faster.
162
207
 
163
208
  ## Example Output
164
209
 
@@ -55,9 +55,6 @@ const hr1 = () => "─".repeat(65); // horizontal line -
55
55
  const hr2 = () => "=".repeat(65); // horizontal line =
56
56
  const tab_a = () => " ".repeat(3); // indentation for formatting the terminal output.
57
57
  const tab_b = () => " ".repeat(6);
58
- const cr_a = () => "\n".repeat(1);
59
- const cr_b = () => "\n".repeat(2);
60
-
61
58
 
62
59
  // ---------------------------------------------------------------------------
63
60
  // CLI arguments
@@ -68,6 +65,7 @@ const TARGET = args[0];
68
65
  const DRY_RUN = args.includes("--dry-run");
69
66
  const RUN_UPLOAD_LIST = args.includes("--upload-list");
70
67
  const RUN_DOWNLOAD_LIST = args.includes("--download-list");
68
+ const SKIP_SYNC = args.includes("--skip-sync");
71
69
 
72
70
  // logLevel override via CLI (optional)
73
71
  let cliLogLevel = null;
@@ -80,6 +78,14 @@ if (!TARGET) {
80
78
  process.exit(1);
81
79
  }
82
80
 
81
+ // Wenn jemand --skip-sync ohne Listen benutzt → sinnlos, also abbrechen
82
+ if (SKIP_SYNC && !RUN_UPLOAD_LIST && !RUN_DOWNLOAD_LIST) {
83
+ console.error(
84
+ pc.red("❌ --skip-sync requires at least --upload-list or --download-list.")
85
+ );
86
+ process.exit(1);
87
+ }
88
+
83
89
  // ---------------------------------------------------------------------------
84
90
  // Load config file
85
91
  // ---------------------------------------------------------------------------
@@ -104,6 +110,93 @@ if (!CONFIG_RAW.connections || typeof CONFIG_RAW.connections !== "object") {
104
110
  process.exit(1);
105
111
  }
106
112
 
113
+ // ---------------------------------------------------------------------------
114
+ // Logging helpers (Terminal + optional Logfile)
115
+ // ---------------------------------------------------------------------------
116
+ // Default: .sync.{TARGET}.log, kann via config.logFile überschrieben werden
117
+ const DEFAULT_LOG_FILE = `.sync.${TARGET}.log`;
118
+ const rawLogFilePattern = CONFIG_RAW.logFile || DEFAULT_LOG_FILE;
119
+ const LOG_FILE = path.resolve(
120
+ rawLogFilePattern.replace("{target}", TARGET)
121
+ );
122
+ let LOG_STREAM = null;
123
+
124
+ /** einmalig Logfile-Stream öffnen */
125
+ function openLogFile() {
126
+ if (!LOG_FILE) return;
127
+ if (!LOG_STREAM) {
128
+ LOG_STREAM = fs.createWriteStream(LOG_FILE, {
129
+ flags: "w", // pro Lauf überschreiben
130
+ encoding: "utf8",
131
+ });
132
+ }
133
+ }
134
+
135
+ /** eine fertige Zeile ins Logfile schreiben (ohne Einfluss auf Terminal) */
136
+ function writeLogLine(line) {
137
+ if (!LOG_STREAM) return;
138
+ // ANSI-Farbsequenzen aus der Log-Zeile entfernen
139
+ const clean =
140
+ typeof line === "string"
141
+ ? line.replace(/\x1b\[[0-9;]*m/g, "")
142
+ : String(line).replace(/\x1b\[[0-9;]*m/g, "");
143
+ try {
144
+ LOG_STREAM.write(clean + "\n");
145
+ } catch {
146
+ // falls Stream schon zu ist, einfach ignorieren – verhindert ERR_STREAM_WRITE_AFTER_END
147
+ }
148
+ }
149
+
150
+ /** Konsole + Logfile (normal) */
151
+ function rawConsoleLog(...msg) {
152
+ clearProgressLine();
153
+ console.log(...msg);
154
+ const line = msg
155
+ .map((m) => (typeof m === "string" ? m : String(m)))
156
+ .join(" ");
157
+ writeLogLine(line);
158
+ }
159
+
160
+ function rawConsoleError(...msg) {
161
+ clearProgressLine();
162
+ console.error(...msg);
163
+ const line = msg
164
+ .map((m) => (typeof m === "string" ? m : String(m)))
165
+ .join(" ");
166
+ writeLogLine("[ERROR] " + line);
167
+ }
168
+
169
+ function rawConsoleWarn(...msg) {
170
+ clearProgressLine();
171
+ console.warn(...msg);
172
+ const line = msg
173
+ .map((m) => (typeof m === "string" ? m : String(m)))
174
+ .join(" ");
175
+ writeLogLine("[WARN] " + line);
176
+ }
177
+
178
+ // High-level Helfer, die du überall im Script schon verwendest:
179
+ function log(...msg) {
180
+ rawConsoleLog(...msg);
181
+ }
182
+
183
+ function vlog(...msg) {
184
+ if (!IS_VERBOSE) return;
185
+ rawConsoleLog(...msg);
186
+ }
187
+
188
+ function elog(...msg) {
189
+ rawConsoleError(...msg);
190
+ }
191
+
192
+ function wlog(...msg) {
193
+ rawConsoleWarn(...msg);
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Connection
198
+ // ---------------------------------------------------------------------------
199
+
107
200
  const TARGET_CONFIG = CONFIG_RAW.connections[TARGET];
108
201
  if (!TARGET_CONFIG) {
109
202
  console.error(
@@ -149,6 +242,38 @@ const ANALYZE_CHUNK = PROGRESS.analyzeChunk ?? (IS_VERBOSE ? 1 : 10);
149
242
  const INCLUDE = CONFIG_RAW.include ?? [];
150
243
  const BASE_EXCLUDE = CONFIG_RAW.exclude ?? [];
151
244
 
245
+ // textExtensions
246
+ const TEXT_EXT = CONFIG_RAW.textExtensions ?? [
247
+ ".html",
248
+ ".htm",
249
+ ".xml",
250
+ ".txt",
251
+ ".json",
252
+ ".js",
253
+ ".mjs",
254
+ ".cjs",
255
+ ".css",
256
+ ".md",
257
+ ".svg",
258
+ ];
259
+
260
+ // mediaExtensions – aktuell nur Meta, aber schon konfigurierbar
261
+ const MEDIA_EXT = CONFIG_RAW.mediaExtensions ?? [
262
+ ".jpg",
263
+ ".jpeg",
264
+ ".png",
265
+ ".gif",
266
+ ".webp",
267
+ ".avif",
268
+ ".mp4",
269
+ ".mov",
270
+ ".mp3",
271
+ ".wav",
272
+ ".ogg",
273
+ ".flac",
274
+ ".pdf",
275
+ ];
276
+
152
277
  // Special: Lists for targeted uploads/downloads
153
278
  function normalizeList(list) {
154
279
  if (!Array.isArray(list)) return [];
@@ -166,25 +291,13 @@ const UPLOAD_LIST = normalizeList(CONFIG_RAW.uploadList ?? []);
166
291
  const DOWNLOAD_LIST = normalizeList(CONFIG_RAW.downloadList ?? []);
167
292
 
168
293
  // Effektive Exclude-Liste: explizites exclude + Upload/Download-Listen
294
+ // → diese Dateien werden im „normalen“ Sync nicht angerührt,
295
+ // sondern nur über die Bypass-Mechanik behandelt.
169
296
  const EXCLUDE = [...BASE_EXCLUDE, ...UPLOAD_LIST, ...DOWNLOAD_LIST];
170
297
 
171
298
  // List of ALL files that were excluded due to uploadList/downloadList
172
299
  const AUTO_EXCLUDED = new Set();
173
300
 
174
- const TEXT_EXT = CONFIG_RAW.textExtensions ?? [
175
- ".html",
176
- ".htm",
177
- ".xml",
178
- ".txt",
179
- ".json",
180
- ".js",
181
- ".mjs",
182
- ".cjs",
183
- ".css",
184
- ".md",
185
- ".svg",
186
- ];
187
-
188
301
  // Cache file name per connection
189
302
  const syncCacheName = TARGET_CONFIG.syncCache || `.sync-cache.${TARGET}.json`;
190
303
  const CACHE_PATH = path.resolve(syncCacheName);
@@ -245,11 +358,14 @@ let progressActive = false;
245
358
 
246
359
  function clearProgressLine() {
247
360
  if (!process.stdout.isTTY || !progressActive) return;
248
- const width = process.stdout.columns || 80;
249
- const blank = " ".repeat(width);
250
361
 
251
- // Beide Progress-Zeilen leeren
252
- process.stdout.write("\r" + blank + cr_a() + blank + "\r");
362
+ // Zwei Progress-Zeilen ohne zusätzliche Newlines leeren:
363
+ // Cursor steht nach updateProgress2() auf der ersten Zeile.
364
+ process.stdout.write("\r"); // an Zeilenanfang
365
+ process.stdout.write("\x1b[2K"); // erste Zeile löschen
366
+ process.stdout.write("\x1b[1B"); // eine Zeile nach unten
367
+ process.stdout.write("\x1b[2K"); // zweite Zeile löschen
368
+ process.stdout.write("\x1b[1A"); // wieder nach oben
253
369
 
254
370
  progressActive = false;
255
371
  }
@@ -258,27 +374,6 @@ function toPosix(p) {
258
374
  return p.split(path.sep).join("/");
259
375
  }
260
376
 
261
- function log(...msg) {
262
- clearProgressLine();
263
- console.log(...msg);
264
- }
265
-
266
- function vlog(...msg) {
267
- if (!IS_VERBOSE) return;
268
- clearProgressLine();
269
- console.log(...msg);
270
- }
271
-
272
- function elog(...msg) {
273
- clearProgressLine();
274
- console.error(...msg);
275
- }
276
-
277
- function wlog(...msg) {
278
- clearProgressLine();
279
- console.warn(...msg);
280
- }
281
-
282
377
  function matchesAny(patterns, relPath) {
283
378
  if (!patterns || patterns.length === 0) return false;
284
379
  return patterns.some((pattern) => minimatch(relPath, pattern, { dot: true }));
@@ -303,6 +398,11 @@ function isTextFile(relPath) {
303
398
  return TEXT_EXT.includes(ext);
304
399
  }
305
400
 
401
+ function isMediaFile(relPath) {
402
+ const ext = path.extname(relPath).toLowerCase();
403
+ return MEDIA_EXT.includes(ext);
404
+ }
405
+
306
406
  function shortenPathForProgress(rel) {
307
407
  if (!rel) return "";
308
408
  const parts = rel.split("/");
@@ -320,17 +420,28 @@ function shortenPathForProgress(rel) {
320
420
  return `…/${prev}/${last}`;
321
421
  }
322
422
 
323
- // Two-line progress bar
423
+ // Two-line progress bar (for terminal) + 1-line log entry
324
424
  function updateProgress2(prefix, current, total, rel = "") {
425
+ const short = rel ? shortenPathForProgress(rel) : "";
426
+
427
+ //Log file: always as a single line with **full** rel path
428
+ const base =
429
+ total && total > 0
430
+ ? `${prefix}${current}/${total} Files`
431
+ : `${prefix}${current} Files`;
432
+ writeLogLine(
433
+ `[progress] ${base}${rel ? " – " + rel : ""}`
434
+ );
435
+
325
436
  if (!process.stdout.isTTY) {
326
- // Fallback für Pipes / Logs
437
+ // Fallback-Terminal
327
438
  if (total && total > 0) {
328
439
  const percent = ((current / total) * 100).toFixed(1);
329
440
  console.log(
330
- `${tab_a()}${prefix}${current}/${total} Files (${percent}%) – ${rel}`
441
+ `${tab_a()}${prefix}${current}/${total} Files (${percent}%) – ${short}`
331
442
  );
332
443
  } else {
333
- console.log(`${tab_a()}${prefix}${current} Files – ${rel}`);
444
+ console.log(`${tab_a()}${prefix}${current} Files – ${short}`);
334
445
  }
335
446
  return;
336
447
  }
@@ -346,14 +457,13 @@ function updateProgress2(prefix, current, total, rel = "") {
346
457
  line1 = `${tab_a()}${prefix}${current} Files`;
347
458
  }
348
459
 
349
- const short = rel ? shortenPathForProgress(rel) : "";
350
460
  let line2 = short;
351
461
 
352
462
  if (line1.length > width) line1 = line1.slice(0, width - 1);
353
463
  if (line2.length > width) line2 = line2.slice(0, width - 1);
354
464
 
355
465
  // zwei Zeilen überschreiben
356
- process.stdout.write("\r" + line1.padEnd(width) + cr_a());
466
+ process.stdout.write("\r" + line1.padEnd(width) + "\n");
357
467
  process.stdout.write(line2.padEnd(width));
358
468
 
359
469
  // Cursor wieder nach oben (auf die Fortschrittszeile)
@@ -383,8 +493,8 @@ async function runTasks(items, workerCount, handler, label = "Tasks") {
383
493
  elog(pc.red(`${tab_a()}⚠️ Error in ${label}:`), err.message || err);
384
494
  }
385
495
  done += 1;
386
- if (done % 10 === 0 || done === total) {
387
- updateProgress2(`${tab_a()}${label}: `, done, total);
496
+ if (done === 1 || done % 10 === 0 || done === total) {
497
+ updateProgress2(`${label}: `, done, total, item.rel ?? "");
388
498
  }
389
499
  }
390
500
  }
@@ -423,13 +533,13 @@ async function walkLocal(root) {
423
533
  size: stat.size,
424
534
  mtimeMs: stat.mtimeMs,
425
535
  isText: isTextFile(rel),
536
+ isMedia: isMediaFile(rel),
426
537
  });
427
538
 
428
539
  scanned += 1;
429
540
  const chunk = IS_VERBOSE ? 1 : SCAN_CHUNK;
430
541
  if (scanned === 1 || scanned % chunk === 0) {
431
- // totally unknown total = 0 → no automatic \n
432
- updateProgress2(`${tab_a()}Scan local: `, scanned, 0, rel);
542
+ updateProgress2("Scan local: ", scanned, 0, rel);
433
543
  }
434
544
  }
435
545
  }
@@ -438,15 +548,38 @@ async function walkLocal(root) {
438
548
  await recurse(root);
439
549
 
440
550
  if (scanned > 0) {
441
- // last line + neat finish
442
- updateProgress2(`${tab_a()}Scan local: `, scanned, 0, "fertig");
443
- process.stdout.write(cr_a());
551
+ updateProgress2("Scan local: ", scanned, 0, "fertig");
552
+ process.stdout.write("\n");
444
553
  progressActive = false;
445
554
  }
446
555
 
447
556
  return result;
448
557
  }
449
558
 
559
+ // Plain walker für Bypass (ignoriert INCLUDE/EXCLUDE)
560
+ async function walkLocalPlain(root) {
561
+ const result = new Map();
562
+
563
+ async function recurse(current) {
564
+ const entries = await fsp.readdir(current, { withFileTypes: true });
565
+ for (const entry of entries) {
566
+ const full = path.join(current, entry.name);
567
+ if (entry.isDirectory()) {
568
+ await recurse(full);
569
+ } else if (entry.isFile()) {
570
+ const rel = toPosix(path.relative(root, full));
571
+ result.set(rel, {
572
+ rel,
573
+ localPath: full,
574
+ });
575
+ }
576
+ }
577
+ }
578
+
579
+ await recurse(root);
580
+ return result;
581
+ }
582
+
450
583
  // ---------------------------------------------------------------------------
451
584
  // Remote walker (recursive, all subdirectories) – respects INCLUDE/EXCLUDE
452
585
  // ---------------------------------------------------------------------------
@@ -480,23 +613,51 @@ async function walkRemote(sftp, remoteRoot) {
480
613
  scanned += 1;
481
614
  const chunk = IS_VERBOSE ? 1 : SCAN_CHUNK;
482
615
  if (scanned === 1 || scanned % chunk === 0) {
483
- updateProgress2(`${tab_a()}Scan remote: `, scanned, 0, rel);
616
+ updateProgress2("Scan remote: ", scanned, 0, rel);
484
617
  }
485
618
  }
486
619
  }
487
620
  }
488
621
 
489
- await recurse(remoteRoot);
622
+ await recurse(remoteRoot, "");
490
623
 
491
624
  if (scanned > 0) {
492
- updateProgress2(`${tab_a()}Scan remote: `, scanned, 0, "fertig");
493
- process.stdout.write(cr_a());
625
+ updateProgress2("Scan remote: ", scanned, 0, "fertig");
626
+ process.stdout.write("\n");
494
627
  progressActive = false;
495
628
  }
496
629
 
497
630
  return result;
498
631
  }
499
632
 
633
+ // Plain walker für Bypass (ignoriert INCLUDE/EXCLUDE)
634
+ async function walkRemotePlain(sftp, remoteRoot) {
635
+ const result = new Map();
636
+
637
+ async function recurse(remoteDir, prefix) {
638
+ const items = await sftp.list(remoteDir);
639
+
640
+ for (const item of items) {
641
+ if (!item.name || item.name === "." || item.name === "..") continue;
642
+
643
+ const full = path.posix.join(remoteDir, item.name);
644
+ const rel = prefix ? `${prefix}/${item.name}` : item.name;
645
+
646
+ if (item.type === "d") {
647
+ await recurse(full, rel);
648
+ } else {
649
+ result.set(rel, {
650
+ rel,
651
+ remotePath: full,
652
+ });
653
+ }
654
+ }
655
+ }
656
+
657
+ await recurse(remoteRoot, "");
658
+ return result;
659
+ }
660
+
500
661
  // ---------------------------------------------------------------------------
501
662
  // Hash helper for binaries (streaming, memory-efficient)
502
663
  // ---------------------------------------------------------------------------
@@ -609,25 +770,140 @@ function describeSftpError(err) {
609
770
  return "";
610
771
  }
611
772
 
773
+ // ---------------------------------------------------------------------------
774
+ // Bypass-only Mode (uploadList / downloadList ohne normalen Sync)
775
+ // ---------------------------------------------------------------------------
776
+
777
+ async function collectUploadTargets() {
778
+ const all = await walkLocalPlain(CONNECTION.localRoot);
779
+ const results = [];
780
+
781
+ for (const [rel, meta] of all.entries()) {
782
+ if (matchesAny(UPLOAD_LIST, rel)) {
783
+ const remotePath = path.posix.join(CONNECTION.remoteRoot, rel);
784
+ results.push({
785
+ rel,
786
+ localPath: meta.localPath,
787
+ remotePath,
788
+ });
789
+ }
790
+ }
791
+
792
+ return results;
793
+ }
794
+
795
+ async function collectDownloadTargets(sftp) {
796
+ const all = await walkRemotePlain(sftp, CONNECTION.remoteRoot);
797
+ const results = [];
798
+
799
+ for (const [rel, meta] of all.entries()) {
800
+ if (matchesAny(DOWNLOAD_LIST, rel)) {
801
+ const localPath = path.join(CONNECTION.localRoot, rel);
802
+ results.push({
803
+ rel,
804
+ remotePath: meta.remotePath,
805
+ localPath,
806
+ });
807
+ }
808
+ }
809
+
810
+ return results;
811
+ }
812
+
813
+ async function performBypassOnly(sftp) {
814
+ log("");
815
+ log(pc.bold(pc.cyan("🚀 Bypass-Only Mode (skip-sync)")));
816
+
817
+ if (RUN_UPLOAD_LIST) {
818
+ log("");
819
+ log(pc.bold(pc.cyan("⬆️ Upload-Bypass (uploadList) …")));
820
+ const targets = await collectUploadTargets();
821
+ log(`${tab_a()}→ ${targets.length} files from uploadList`);
822
+
823
+ if (!DRY_RUN) {
824
+ await runTasks(
825
+ targets,
826
+ CONNECTION.workers,
827
+ async ({ localPath, remotePath, rel }) => {
828
+ const remoteDir = path.posix.dirname(remotePath);
829
+ try {
830
+ await sftp.mkdir(remoteDir, true);
831
+ } catch {
832
+ // Directory may already exist
833
+ }
834
+ await sftp.put(localPath, remotePath);
835
+ vlog(`${tab_a()}${ADD} Uploaded (bypass): ${rel}`);
836
+ },
837
+ "Bypass Uploads"
838
+ );
839
+ } else {
840
+ for (const t of targets) {
841
+ log(`${tab_a()}${ADD} (DRY-RUN) Upload: ${t.rel}`);
842
+ }
843
+ }
844
+ }
845
+
846
+ if (RUN_DOWNLOAD_LIST) {
847
+ log("");
848
+ log(pc.bold(pc.cyan("⬇️ Download-Bypass (downloadList) …")));
849
+ const targets = await collectDownloadTargets(sftp);
850
+ log(`${tab_a()}→ ${targets.length} files from downloadList`);
851
+
852
+ if (!DRY_RUN) {
853
+ await runTasks(
854
+ targets,
855
+ CONNECTION.workers,
856
+ async ({ remotePath, localPath, rel }) => {
857
+ const localDir = path.dirname(localPath);
858
+ await fsp.mkdir(localDir, { recursive: true });
859
+ await sftp.get(remotePath, localPath);
860
+ vlog(`${tab_a()}${CHA} Downloaded (bypass): ${rel}`);
861
+ },
862
+ "Bypass Downloads"
863
+ );
864
+ } else {
865
+ for (const t of targets) {
866
+ log(`${tab_a()}${CHA} (DRY-RUN) Download: ${t.rel}`);
867
+ }
868
+ }
869
+ }
870
+
871
+ log("");
872
+ log(pc.bold(pc.green("✅ Bypass-only run finished.")));
873
+ }
874
+
612
875
  // ---------------------------------------------------------------------------
613
876
  // MAIN
614
877
  // ---------------------------------------------------------------------------
615
878
 
879
+ async function initLogFile() {
880
+ if (!LOG_FILE) return;
881
+ const dir = path.dirname(LOG_FILE);
882
+ await fsp.mkdir(dir, { recursive: true });
883
+ LOG_STREAM = fs.createWriteStream(LOG_FILE, {
884
+ flags: "w",
885
+ encoding: "utf8",
886
+ });
887
+ }
888
+
616
889
  async function main() {
617
890
  const start = Date.now();
618
891
 
619
- log(`${cr_b()}${hr2()}`);
620
- log(
621
- pc.bold(
622
- `🔐 SFTP Push-Synchronisation: sftp-push-sync v${pkg.version} [logLevel=${LOG_LEVEL}]`
623
- )
624
- );
892
+ await initLogFile();
893
+
894
+ // Header-Abstand wie gehabt: zwei Leerzeilen davor
895
+ log("\n" + hr2());
896
+ log(pc.bold(`🔐 SFTP Push-Synchronisation: sftp-push-sync v${pkg.version}`));
897
+ log(`${tab_a()}LogLevel: ${LOG_LEVEL}`);
625
898
  log(`${tab_a()}Connection: ${pc.cyan(TARGET)}`);
626
899
  log(`${tab_a()}Worker: ${CONNECTION.workers}`);
627
- log(`${tab_a()}Host: ${pc.green(CONNECTION.host)}:${pc.green(CONNECTION.port)}`);
900
+ log(
901
+ `${tab_a()}Host: ${pc.green(CONNECTION.host)}:${pc.green(CONNECTION.port)}`
902
+ );
628
903
  log(`${tab_a()}Local: ${pc.green(CONNECTION.localRoot)}`);
629
904
  log(`${tab_a()}Remote: ${pc.green(CONNECTION.remoteRoot)}`);
630
905
  if (DRY_RUN) log(pc.yellow(`${tab_a()}Mode: DRY-RUN (no changes)`));
906
+ if (SKIP_SYNC) log(pc.yellow(`${tab_a()}Mode: SKIP-SYNC (bypass only)`));
631
907
  if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST) {
632
908
  log(
633
909
  pc.blue(
@@ -637,7 +913,10 @@ async function main() {
637
913
  )
638
914
  );
639
915
  }
640
- log(`${hr1()}${cr_a()}`);
916
+ if (LOG_FILE) {
917
+ log(`${tab_a()}LogFile: ${pc.cyan(LOG_FILE)}`);
918
+ }
919
+ log(hr1());
641
920
 
642
921
  const sftp = new SftpClient();
643
922
  let connected = false;
@@ -647,6 +926,7 @@ async function main() {
647
926
  const toDelete = [];
648
927
 
649
928
  try {
929
+ log("");
650
930
  log(pc.cyan("🔌 Connecting to SFTP server …"));
651
931
  await sftp.connect({
652
932
  host: CONNECTION.host,
@@ -665,7 +945,25 @@ async function main() {
665
945
  process.exit(1);
666
946
  }
667
947
 
668
- log(cr_a() + pc.bold(pc.cyan("📥 Phase 1: Scan local files …")));
948
+ // -------------------------------------------------------------
949
+ // SKIP-SYNC-Modus → nur Bypass mit Listen
950
+ // -------------------------------------------------------------
951
+ if (SKIP_SYNC) {
952
+ await performBypassOnly(sftp);
953
+ const duration = ((Date.now() - start) / 1000).toFixed(2);
954
+ log("");
955
+ log(pc.bold(pc.cyan("📊 Summary (bypass only):")));
956
+ log(`${tab_a()}Duration: ${pc.green(duration + " s")}`);
957
+ return;
958
+ }
959
+
960
+ // -------------------------------------------------------------
961
+ // Normaler Sync (inkl. evtl. paralleler Listen-Excludes)
962
+ // -------------------------------------------------------------
963
+
964
+ // Phase 1 – mit exakt einer Leerzeile davor
965
+ log("");
966
+ log(pc.bold(pc.cyan("📥 Phase 1: Scan local files …")));
669
967
  const local = await walkLocal(CONNECTION.localRoot);
670
968
  log(`${tab_a()}→ ${local.size} local files`);
671
969
 
@@ -678,14 +976,17 @@ async function main() {
678
976
  log("");
679
977
  }
680
978
 
681
- log(cr_a() + pc.bold(pc.cyan("📤 Phase 2: Scan remote files …")));
979
+ // Phase 2 auch mit einer Leerzeile davor
980
+ log("");
981
+ log(pc.bold(pc.cyan("📤 Phase 2: Scan remote files …")));
682
982
  const remote = await walkRemote(sftp, CONNECTION.remoteRoot);
683
- log(`${tab_a()}→ ${remote.size} remote files${cr_a()}`);
983
+ log(`${tab_a()}→ ${remote.size} remote files`);
984
+ log("");
684
985
 
685
986
  const localKeys = new Set(local.keys());
686
987
  const remoteKeys = new Set(remote.keys());
687
988
 
688
- log(cr_a() + pc.bold(pc.cyan("🔎 Phase 3: Compare & decide …")));
989
+ log(pc.bold(pc.cyan("🔎 Phase 3: Compare & decide …")));
689
990
  const totalToCheck = localKeys.size;
690
991
  let checkedCount = 0;
691
992
 
@@ -699,7 +1000,7 @@ async function main() {
699
1000
  checkedCount % chunk === 0 ||
700
1001
  checkedCount === totalToCheck
701
1002
  ) {
702
- updateProgress2(" Analyse: ", checkedCount, totalToCheck, rel);
1003
+ updateProgress2("Analyse: ", checkedCount, totalToCheck, rel);
703
1004
  }
704
1005
 
705
1006
  const l = local.get(rel);
@@ -749,7 +1050,9 @@ async function main() {
749
1050
 
750
1051
  toUpdate.push({ rel, local: l, remote: r, remotePath });
751
1052
  if (!IS_LACONIC) {
752
- log(`${tab_a()}${CHA} ${pc.yellow("Content changed (Text):")} ${rel}`);
1053
+ log(
1054
+ `${tab_a()}${CHA} ${pc.yellow("Content changed (Text):")} ${rel}`
1055
+ );
753
1056
  }
754
1057
  } else {
755
1058
  // Binary: Hash comparison with cache
@@ -781,12 +1084,12 @@ async function main() {
781
1084
 
782
1085
  // Wenn Phase 3 nichts gefunden hat, explizit sagen
783
1086
  if (toAdd.length === 0 && toUpdate.length === 0) {
1087
+ log("");
784
1088
  log(`${tab_a()}No differences found. Everything is up to date.`);
785
1089
  }
786
1090
 
787
- log(
788
- cr_a() + pc.bold(pc.cyan("🧹 Phase 4: Removing orphaned remote files …"))
789
- );
1091
+ log("");
1092
+ log(pc.bold(pc.cyan("🧹 Phase 4: Removing orphaned remote files …")));
790
1093
  for (const rel of remoteKeys) {
791
1094
  if (!localKeys.has(rel)) {
792
1095
  const r = remote.get(rel);
@@ -807,7 +1110,8 @@ async function main() {
807
1110
  // -------------------------------------------------------------------
808
1111
 
809
1112
  if (!DRY_RUN) {
810
- log(cr_a() + pc.bold(pc.cyan("🚚 Phase 5: Apply changes …")));
1113
+ log("");
1114
+ log(pc.bold(pc.cyan("🚚 Phase 5: Apply changes …")));
811
1115
 
812
1116
  // Upload new files
813
1117
  await runTasks(
@@ -859,82 +1163,14 @@ async function main() {
859
1163
  "Deletes"
860
1164
  );
861
1165
  } else {
1166
+ log("");
862
1167
  log(
863
1168
  pc.yellow(
864
- `${cr_a()}💡 DRY-RUN: Connection tested, no files transferred or deleted.`
1169
+ "💡 DRY-RUN: Connection tested, no files transferred or deleted."
865
1170
  )
866
1171
  );
867
1172
  }
868
1173
 
869
- // -------------------------------------------------------------------
870
- // Phase 6: optional uploadList / downloadList
871
- // -------------------------------------------------------------------
872
-
873
- if (RUN_UPLOAD_LIST && UPLOAD_LIST.length > 0) {
874
- log(
875
- cr_a() +
876
- pc.bold(pc.cyan("⬆️ Extra Phase: Upload-List (explicit files) …"))
877
- );
878
-
879
- const tasks = UPLOAD_LIST.map((rel) => ({
880
- rel,
881
- localPath: path.join(CONNECTION.localRoot, rel),
882
- remotePath: path.posix.join(CONNECTION.remoteRoot, toPosix(rel)),
883
- }));
884
-
885
- if (DRY_RUN) {
886
- for (const t of tasks) {
887
- log(`${tab_a()}${ADD} would upload (uploadList): ${t.rel}`);
888
- }
889
- } else {
890
- await runTasks(
891
- tasks,
892
- CONNECTION.workers,
893
- async ({ localPath, remotePath, rel }) => {
894
- const remoteDir = path.posix.dirname(remotePath);
895
- try {
896
- await sftp.mkdir(remoteDir, true);
897
- } catch {
898
- // ignore
899
- }
900
- await sftp.put(localPath, remotePath);
901
- log(`${tab_a()}${ADD} uploadList: ${rel}`);
902
- },
903
- "Upload-List"
904
- );
905
- }
906
- }
907
-
908
- if (RUN_DOWNLOAD_LIST && DOWNLOAD_LIST.length > 0) {
909
- log(
910
- cr_a() +
911
- pc.bold(pc.cyan("⬇️ Extra Phase: Download-List (explicit files) …"))
912
- );
913
-
914
- const tasks = DOWNLOAD_LIST.map((rel) => ({
915
- rel,
916
- remotePath: path.posix.join(CONNECTION.remoteRoot, toPosix(rel)),
917
- localPath: path.join(CONNECTION.localRoot, rel),
918
- }));
919
-
920
- if (DRY_RUN) {
921
- for (const t of tasks) {
922
- log(`${tab_a()}${ADD} would download (downloadList): ${t.rel}`);
923
- }
924
- } else {
925
- await runTasks(
926
- tasks,
927
- CONNECTION.workers,
928
- async ({ remotePath, localPath, rel }) => {
929
- await fsp.mkdir(path.dirname(localPath), { recursive: true });
930
- await sftp.fastGet(remotePath, localPath);
931
- log(`${tab_a()}${ADD} downloadList: ${rel}`);
932
- },
933
- "Download-List"
934
- );
935
- }
936
- }
937
-
938
1174
  const duration = ((Date.now() - start) / 1000).toFixed(2);
939
1175
 
940
1176
  // Write cache safely at the end
@@ -942,18 +1178,22 @@ async function main() {
942
1178
 
943
1179
  // Summary
944
1180
  log(hr1());
945
- log(cr_a() + pc.bold(pc.cyan("📊 Summary:")));
1181
+ log("");
1182
+ log(pc.bold(pc.cyan("📊 Summary:")));
946
1183
  log(`${tab_a()}Duration: ${pc.green(duration + " s")}`);
947
1184
  log(`${tab_a()}${ADD} Added : ${toAdd.length}`);
948
1185
  log(`${tab_a()}${CHA} Changed: ${toUpdate.length}`);
949
1186
  log(`${tab_a()}${DEL} Deleted: ${toDelete.length}`);
950
1187
  if (AUTO_EXCLUDED.size > 0) {
951
1188
  log(
952
- `${tab_a()}${EXC} Excluded via uploadList | downloadList: ${AUTO_EXCLUDED.size}`
1189
+ `${tab_a()}${EXC} Excluded via uploadList | downloadList: ${
1190
+ AUTO_EXCLUDED.size
1191
+ }`
953
1192
  );
954
1193
  }
955
1194
  if (toAdd.length || toUpdate.length || toDelete.length) {
956
- log(`${cr_a()}📄 Changes:`);
1195
+ log("");
1196
+ log("📄 Changes:");
957
1197
  [...toAdd.map((t) => t.rel)]
958
1198
  .sort()
959
1199
  .forEach((f) => console.log(`${tab_a()}${ADD} ${f}`));
@@ -964,10 +1204,12 @@ async function main() {
964
1204
  .sort()
965
1205
  .forEach((f) => console.log(`${tab_a()}${DEL} ${f}`));
966
1206
  } else {
967
- log(`${cr_a()}No changes.`);
1207
+ log("");
1208
+ log("No changes.");
968
1209
  }
969
1210
 
970
- log(cr_a() + pc.bold(pc.green("✅ Sync complete.")));
1211
+ log("");
1212
+ log(pc.bold(pc.green("✅ Sync complete.")));
971
1213
  } catch (err) {
972
1214
  const hint = describeSftpError(err);
973
1215
  elog(pc.red("❌ Synchronisation error:"), err.message || err);
@@ -996,8 +1238,15 @@ async function main() {
996
1238
  e.message || e
997
1239
  );
998
1240
  }
1241
+
1242
+ // Abschlusslinie + Leerzeile **vor** dem Schließen des Logfiles
1243
+ log(hr2());
1244
+ log("");
1245
+
1246
+ if (LOG_STREAM) {
1247
+ LOG_STREAM.end();
1248
+ }
999
1249
  }
1000
- log(`${hr2()}${cr_b()}`);
1001
1250
  }
1002
1251
 
1003
- main();
1252
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sftp-push-sync",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "SFTP sync tool for Hugo projects (local to remote, with hash cache)",
5
5
  "type": "module",
6
6
  "bin": {