sftp-push-sync 1.0.21 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## [2.0.0] - 2025-11-18
4
+
5
+ ### Breaking
6
+
7
+ - CLI flags renamed:
8
+ - `--upload-list` → `--sidecar-upload`
9
+ - `--download-list` → `--sidecar-download`
10
+ - Configuration per connection restructured:
11
+ - `localRoot` / `remoteRoot` now under `sync`
12
+ - `sidecar` block for sidecar uploads/downloads
13
+
14
+ ### Added
15
+
16
+ - Separate `sidecar.localRoot` / `sidecar.remoteRoot` für Upload-/Download-Listen.
17
+
18
+ ---
package/README.md CHANGED
@@ -23,6 +23,12 @@ Features:
23
23
 
24
24
  The file `sftp-push-sync.mjs` is pure JavaScript (ESM), not TypeScript. Node.js can execute it directly as long as "type": "module" is specified in package.json or the file has the extension .mjs.
25
25
 
26
+ ## Breaking changes in 2.0.0
27
+
28
+ - The flags `--upload-list` / `--download-list` have been replaced by
29
+ `--sidecar-upload` / `--sidecar-download`.
30
+ - The settings for sidecars are now located in the `sidecar` block of the connection.
31
+
26
32
  ## Install
27
33
 
28
34
  ```bash
@@ -47,22 +53,40 @@ Create a `sync.config.json` in the root folder of your project:
47
53
  "port": 23,
48
54
  "user": "ftpuser",
49
55
  "password": "mypassword",
50
- "remoteRoot": "/folder/",
51
- "localRoot": "public",
52
56
  "syncCache": ".sync-cache.prod.json",
53
- "worker": 3
57
+ "worker": 3,
58
+ "cleanupEmptyDirs": true,
59
+ "sync": {
60
+ "localRoot": "public",
61
+ "remoteRoot": "/folder/"
62
+ },
63
+ "sidecar": {
64
+ "localRoot": "sidecar-local",
65
+ "remoteRoot": "/sidecar-remote/",
66
+ "uploadList": [],
67
+ "downloadList": []
68
+ }
54
69
  },
55
70
  "staging": {
56
71
  "host": "ftpserver02",
57
72
  "port": 22,
58
73
  "user": "ftp_user",
59
74
  "password": "total_secret",
60
- "remoteRoot": "/web/my-page/",
61
- "localRoot": "public",
62
75
  "syncCache": ".sync-cache.staging.json",
63
- "worker": 1
76
+ "worker": 1,
77
+ "sync": {
78
+ "localRoot": "public",
79
+ "remoteRoot": "/web/my-page/"
80
+ },
81
+ "sidecar": {
82
+ "localRoot": "sidecar-local",
83
+ "remoteRoot": "/sidecar-remote/",
84
+ "uploadList": [],
85
+ "downloadList": []
86
+ }
64
87
  }
65
88
  },
89
+ "cleanupEmptyDirs": true,
66
90
  "include": [],
67
91
  "exclude": ["**/.DS_Store", "**/.git/**", "**/node_modules/**"],
68
92
  "textExtensions": [".html",".xml",".txt",".json",".js",".css",".md",".svg"],
@@ -72,9 +96,7 @@ Create a `sync.config.json` in the root folder of your project:
72
96
  "analyzeChunk": 1
73
97
  },
74
98
  "logLevel": "normal",
75
- "logFile": ".sftp-push-sync.{target}.log",
76
- "uploadList": [],
77
- "downloadList": ["download-counter.json"]
99
+ "logFile": ".sftp-push-sync.{target}.log"
78
100
  }
79
101
  ```
80
102
 
@@ -84,16 +106,18 @@ Create a `sync.config.json` in the root folder of your project:
84
106
  # Normal synchronisation
85
107
  node bin/sftp-push-sync.mjs staging
86
108
 
87
- # Consider normal synchronisation + upload list
88
- node bin/sftp-push-sync.mjs staging --upload-list
109
+ # Normal synchronisation + sidecar upload list
110
+ node bin/sftp-push-sync.mjs staging --sidecar-upload
111
+
112
+ # Normal synchronisation + sidecar download list
113
+ node bin/sftp-push-sync.mjs staging --sidecar-download
89
114
 
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
115
+ # Only sidecar lists, no standard synchronisation
116
+ node bin/sftp-push-sync.mjs staging --skip-sync --sidecar-upload
117
+ node bin/sftp-push-sync.mjs staging --skip-sync --sidecar-download
94
118
 
95
119
  # (optional) only run lists dry
96
- node bin/sftp-push-sync.mjs staging --skip-sync --upload-list --dry-run
120
+ node bin/sftp-push-sync.mjs staging --skip-sync --sidecar-upload --dry-run
97
121
  ```
98
122
 
99
123
  - Can be conveniently started via the scripts in `package.json`:
@@ -128,30 +152,29 @@ The dry run is a great way to compare files and fill the cache.
128
152
 
129
153
  A list of files that are excluded from the sync comparison and can be downloaded or uploaded separately.
130
154
 
131
- - `uploadList`
132
- - Relative to localRoot "downloads.json"
133
- - or with subfolders: "data/downloads.json"
134
- - `downloadList`
135
- - Relative to remoteRoot "download-counter.json"
136
- - or e.g. "logs/download-counter.json"
155
+ - `sidecar.uploadList`
156
+ - Relative to sidecar.localRoot, e.g. "downloads.json" or "data/downloads.json"
157
+ - `sidecar.downloadList`
158
+ - Relative to sidecar.remoteRoot, e.g. "download-counter.json" or "logs/download-counter.json"
137
159
 
138
160
  ```bash
139
161
  # normal synchronisation
140
162
  sftp-push-sync staging
141
163
 
142
- # Normal synchronisation + explicitly transfer upload list
143
- sftp-push-sync staging --upload-list
164
+ # Normal synchronisation + explicitly transfer sidecar upload list
165
+ sftp-push-sync staging --sidecar-upload
144
166
 
145
- # just fetch the download list from the server (combined with normal synchronisation)
146
- sftp-push-sync prod --download-list --dry-run # view first
147
- sftp-push-sync prod --download-list # then do
167
+ # just fetch the sidecar download list from the server
168
+ # combined with normal synchronisation
169
+ sftp-push-sync prod --sidecar-download --dry-run # view first
170
+ sftp-push-sync prod --sidecar-download # then do
148
171
  ```
149
172
 
150
- - The `sidecar` is always executed together with `sync` when you use the `--download-list` or `--upload-list` option.
151
- - However, with `--skip-sync`, you can exclude the sync process and only process the sidecar:
173
+ - The sidecar is always executed together with sync when using `--sidecar-download` or `--sidecar-upload`.
174
+ - With `--skip-sync`, you can exclude the sync process and only process the sidecar:
152
175
 
153
176
  ```bash
154
- sftp-push-sync prod --download-list --skip-sync
177
+ sftp-push-sync prod --sidecar-download --skip-sync
155
178
  ```
156
179
 
157
180
  ### Logging Progress
@@ -198,6 +221,14 @@ practical excludes:
198
221
  ]
199
222
  ```
200
223
 
224
+ ### Folder handling
225
+
226
+ Sync only handles files and creates missing directories during upload.
227
+ However, it should also manage directories:
228
+
229
+ - If, for example, a directory is empty because all files have been deleted from it.
230
+ - Or if a directory no longer exists locally.
231
+
201
232
  ## Which files are needed?
202
233
 
203
234
  - `sync.config.json` - The configuration file (with passwords in plain text, so please leave it out of the git repository)
@@ -207,9 +238,9 @@ practical excludes:
207
238
  - The cache files: `.sync-cache.*.json`
208
239
  - The log file: `.sftp-push-sync.{target}.log` (Optional, overwritten with each run)
209
240
 
210
- 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.
241
+ You can safely delete the local cache at any time. The first analysis will then take longer, because remote hashes will be streamed again. After that, everything will run fast.
211
242
 
212
- 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.
243
+ Note: The first run always takes a while, especially with lots of media – so be patient! Once the cache is full, it will be faster.
213
244
 
214
245
  ## Example Output
215
246
 
@@ -8,6 +8,7 @@
8
8
  * 1. Upload new files
9
9
  * 2. Delete remote files that no longer exist locally
10
10
  * 3. Detect changes based on size or modified content and upload them
11
+ * 4. Supports separate sidecar upload/download lists for special files
11
12
  *
12
13
  * Features:
13
14
  * - multiple connections in sync.config.json
@@ -25,6 +26,11 @@
25
26
  * - For example, log files or other special files.
26
27
  * - These files can be downloaded or uploaded separately.
27
28
  *
29
+ * Folder handling:
30
+ * Delete Folders if
31
+ * - If, for example, a directory is empty because all files have been deleted from it.
32
+ * - Or if a directory no longer exists locally.
33
+ *
28
34
  * The file sftp-push-sync.mjs is pure JavaScript (ESM), not TypeScript.
29
35
  * Node.js can execute it directly as long as "type": "module" is specified in package.json
30
36
  * or the file has the extension .mjs.
@@ -63,8 +69,8 @@ const tab_b = () => " ".repeat(6);
63
69
  const args = process.argv.slice(2);
64
70
  const TARGET = args[0];
65
71
  const DRY_RUN = args.includes("--dry-run");
66
- const RUN_UPLOAD_LIST = args.includes("--upload-list");
67
- const RUN_DOWNLOAD_LIST = args.includes("--download-list");
72
+ const RUN_UPLOAD_LIST = args.includes("--sidecar-upload");
73
+ const RUN_DOWNLOAD_LIST = args.includes("--sidecar-download");
68
74
  const SKIP_SYNC = args.includes("--skip-sync");
69
75
 
70
76
  // logLevel override via CLI (optional)
@@ -81,7 +87,9 @@ if (!TARGET) {
81
87
  // Wenn jemand --skip-sync ohne Listen benutzt → sinnlos, also abbrechen
82
88
  if (SKIP_SYNC && !RUN_UPLOAD_LIST && !RUN_DOWNLOAD_LIST) {
83
89
  console.error(
84
- pc.red("❌ --skip-sync requires at least --upload-list or --download-list.")
90
+ pc.red(
91
+ "❌ --skip-sync requires at least --sidecar-upload or --sidecar-download."
92
+ )
85
93
  );
86
94
  process.exit(1);
87
95
  }
@@ -113,12 +121,11 @@ if (!CONFIG_RAW.connections || typeof CONFIG_RAW.connections !== "object") {
113
121
  // ---------------------------------------------------------------------------
114
122
  // Logging helpers (Terminal + optional Logfile)
115
123
  // ---------------------------------------------------------------------------
124
+
116
125
  // Default: .sync.{TARGET}.log, kann via config.logFile überschrieben werden
117
126
  const DEFAULT_LOG_FILE = `.sync.${TARGET}.log`;
118
127
  const rawLogFilePattern = CONFIG_RAW.logFile || DEFAULT_LOG_FILE;
119
- const LOG_FILE = path.resolve(
120
- rawLogFilePattern.replace("{target}", TARGET)
121
- );
128
+ const LOG_FILE = path.resolve(rawLogFilePattern.replace("{target}", TARGET));
122
129
  let LOG_STREAM = null;
123
130
 
124
131
  /** einmalig Logfile-Stream öffnen */
@@ -175,7 +182,7 @@ function rawConsoleWarn(...msg) {
175
182
  writeLogLine("[WARN] " + line);
176
183
  }
177
184
 
178
- // High-level Helfer, die du überall im Script schon verwendest:
185
+ // High-level Helfer
179
186
  function log(...msg) {
180
187
  rawConsoleLog(...msg);
181
188
  }
@@ -205,13 +212,30 @@ if (!TARGET_CONFIG) {
205
212
  process.exit(1);
206
213
  }
207
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) {
220
+ console.error(
221
+ pc.red(
222
+ `❌ Connection '${TARGET}' is missing sync.localRoot or sync.remoteRoot.`
223
+ )
224
+ );
225
+ process.exit(1);
226
+ }
227
+
208
228
  const CONNECTION = {
209
229
  host: TARGET_CONFIG.host,
210
230
  port: TARGET_CONFIG.port ?? 22,
211
231
  user: TARGET_CONFIG.user,
212
232
  password: TARGET_CONFIG.password,
213
- localRoot: path.resolve(TARGET_CONFIG.localRoot),
214
- remoteRoot: TARGET_CONFIG.remoteRoot,
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,
215
239
  workers: TARGET_CONFIG.worker ?? 2,
216
240
  };
217
241
 
@@ -235,6 +259,10 @@ const SCAN_CHUNK = PROGRESS.scanChunk ?? (IS_VERBOSE ? 1 : 100);
235
259
  const ANALYZE_CHUNK = PROGRESS.analyzeChunk ?? (IS_VERBOSE ? 1 : 10);
236
260
  // For >100k files, rather 10–50, for debugging/troubleshooting 1.
237
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
+
238
266
  // ---------------------------------------------------------------------------
239
267
  // Shared config from JSON
240
268
  // ---------------------------------------------------------------------------
@@ -274,7 +302,7 @@ const MEDIA_EXT = CONFIG_RAW.mediaExtensions ?? [
274
302
  ".pdf",
275
303
  ];
276
304
 
277
- // Special: Lists for targeted uploads/downloads
305
+ // Special: Lists for targeted uploads/downloads (per-connection sidecar)
278
306
  function normalizeList(list) {
279
307
  if (!Array.isArray(list)) return [];
280
308
  return list.flatMap((item) =>
@@ -287,15 +315,16 @@ function normalizeList(list) {
287
315
  );
288
316
  }
289
317
 
290
- const UPLOAD_LIST = normalizeList(CONFIG_RAW.uploadList ?? []);
291
- const DOWNLOAD_LIST = normalizeList(CONFIG_RAW.downloadList ?? []);
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 ?? []);
292
321
 
293
322
  // Effektive Exclude-Liste: explizites exclude + Upload/Download-Listen
294
323
  // → diese Dateien werden im „normalen“ Sync nicht angerührt,
295
- // sondern nur über die Bypass-Mechanik behandelt.
324
+ // sondern nur über die Sidecar-Mechanik behandelt.
296
325
  const EXCLUDE = [...BASE_EXCLUDE, ...UPLOAD_LIST, ...DOWNLOAD_LIST];
297
326
 
298
- // List of ALL files that were excluded due to uploadList/downloadList
327
+ // List of ALL files that were ausgeschlossen durch uploadList/downloadList
299
328
  const AUTO_EXCLUDED = new Set();
300
329
 
301
330
  // Cache file name per connection
@@ -384,7 +413,7 @@ function isIncluded(relPath) {
384
413
  if (INCLUDE.length > 0 && !matchesAny(INCLUDE, relPath)) return false;
385
414
  // Exclude-Regeln
386
415
  if (EXCLUDE.length > 0 && matchesAny(EXCLUDE, relPath)) {
387
- // Falls durch Upload/Download-Liste → merken
416
+ // Falls durch Sidecar-Listen → merken
388
417
  if (UPLOAD_LIST.includes(relPath) || DOWNLOAD_LIST.includes(relPath)) {
389
418
  AUTO_EXCLUDED.add(relPath);
390
419
  }
@@ -424,14 +453,12 @@ function shortenPathForProgress(rel) {
424
453
  function updateProgress2(prefix, current, total, rel = "") {
425
454
  const short = rel ? shortenPathForProgress(rel) : "";
426
455
 
427
- //Log file: always as a single line with **full** rel path
456
+ // Log file: always as a single line with **full** rel path
428
457
  const base =
429
458
  total && total > 0
430
459
  ? `${prefix}${current}/${total} Files`
431
460
  : `${prefix}${current} Files`;
432
- writeLogLine(
433
- `[progress] ${base}${rel ? " – " + rel : ""}`
434
- );
461
+ writeLogLine(`[progress] ${base}${rel ? " – " + rel : ""}`);
435
462
 
436
463
  if (!process.stdout.isTTY) {
437
464
  // Fallback-Terminal
@@ -507,6 +534,124 @@ async function runTasks(items, workerCount, handler, label = "Tasks") {
507
534
  await Promise.all(workers);
508
535
  }
509
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
561
+ );
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
+
510
655
  // ---------------------------------------------------------------------------
511
656
  // Local file walker (recursive, all subdirectories)
512
657
  // ---------------------------------------------------------------------------
@@ -597,7 +742,7 @@ async function walkRemote(sftp, remoteRoot) {
597
742
  const full = path.posix.join(remoteDir, item.name);
598
743
  const rel = prefix ? `${prefix}/${item.name}` : item.name;
599
744
 
600
- // Apply include/exclude rules also on remote side
745
+ // Include/Exclude-Regeln auch auf Remote anwenden
601
746
  if (!isIncluded(rel)) continue;
602
747
 
603
748
  if (item.type === "d") {
@@ -771,16 +916,16 @@ function describeSftpError(err) {
771
916
  }
772
917
 
773
918
  // ---------------------------------------------------------------------------
774
- // Bypass-only Mode (uploadList / downloadList ohne normalen Sync)
919
+ // Bypass-only Mode (sidecar-upload / sidecar-download ohne normalen Sync)
775
920
  // ---------------------------------------------------------------------------
776
921
 
777
922
  async function collectUploadTargets() {
778
- const all = await walkLocalPlain(CONNECTION.localRoot);
923
+ const all = await walkLocalPlain(CONNECTION.sidecarLocalRoot);
779
924
  const results = [];
780
925
 
781
926
  for (const [rel, meta] of all.entries()) {
782
927
  if (matchesAny(UPLOAD_LIST, rel)) {
783
- const remotePath = path.posix.join(CONNECTION.remoteRoot, rel);
928
+ const remotePath = path.posix.join(CONNECTION.sidecarRemoteRoot, rel);
784
929
  results.push({
785
930
  rel,
786
931
  localPath: meta.localPath,
@@ -793,12 +938,12 @@ async function collectUploadTargets() {
793
938
  }
794
939
 
795
940
  async function collectDownloadTargets(sftp) {
796
- const all = await walkRemotePlain(sftp, CONNECTION.remoteRoot);
941
+ const all = await walkRemotePlain(sftp, CONNECTION.sidecarRemoteRoot);
797
942
  const results = [];
798
943
 
799
944
  for (const [rel, meta] of all.entries()) {
800
945
  if (matchesAny(DOWNLOAD_LIST, rel)) {
801
- const localPath = path.join(CONNECTION.localRoot, rel);
946
+ const localPath = path.join(CONNECTION.sidecarLocalRoot, rel);
802
947
  results.push({
803
948
  rel,
804
949
  remotePath: meta.remotePath,
@@ -813,10 +958,20 @@ async function collectDownloadTargets(sftp) {
813
958
  async function performBypassOnly(sftp) {
814
959
  log("");
815
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
+ }
816
971
 
817
972
  if (RUN_UPLOAD_LIST) {
818
973
  log("");
819
- log(pc.bold(pc.cyan("⬆️ Upload-Bypass (uploadList) …")));
974
+ log(pc.bold(pc.cyan("⬆️ Upload-Bypass (sidecar-upload) …")));
820
975
  const targets = await collectUploadTargets();
821
976
  log(`${tab_a()}→ ${targets.length} files from uploadList`);
822
977
 
@@ -845,7 +1000,7 @@ async function performBypassOnly(sftp) {
845
1000
 
846
1001
  if (RUN_DOWNLOAD_LIST) {
847
1002
  log("");
848
- log(pc.bold(pc.cyan("⬇️ Download-Bypass (downloadList) …")));
1003
+ log(pc.bold(pc.cyan("⬇️ Download-Bypass (sidecar-download) …")));
849
1004
  const targets = await collectDownloadTargets(sftp);
850
1005
  log(`${tab_a()}→ ${targets.length} files from downloadList`);
851
1006
 
@@ -902,17 +1057,24 @@ async function main() {
902
1057
  );
903
1058
  log(`${tab_a()}Local: ${pc.green(CONNECTION.localRoot)}`);
904
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
+ }
905
1064
  if (DRY_RUN) log(pc.yellow(`${tab_a()}Mode: DRY-RUN (no changes)`));
906
1065
  if (SKIP_SYNC) log(pc.yellow(`${tab_a()}Mode: SKIP-SYNC (bypass only)`));
907
1066
  if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST) {
908
1067
  log(
909
1068
  pc.blue(
910
- `${tab_a()}Extra: ${RUN_UPLOAD_LIST ? "uploadList " : ""}${
911
- RUN_DOWNLOAD_LIST ? "downloadList" : ""
912
- }`
1069
+ `${tab_a()}Extra: ${
1070
+ RUN_UPLOAD_LIST ? "sidecar-upload " : ""
1071
+ }${RUN_DOWNLOAD_LIST ? "sidecar-download" : ""}`
913
1072
  )
914
1073
  );
915
1074
  }
1075
+ if (CLEANUP_EMPTY_DIRS) {
1076
+ log(`${tab_a()}Cleanup empty dirs: ${pc.green("enabled")}`);
1077
+ }
916
1078
  if (LOG_FILE) {
917
1079
  log(`${tab_a()}LogFile: ${pc.cyan(LOG_FILE)}`);
918
1080
  }
@@ -937,7 +1099,7 @@ async function main() {
937
1099
  connected = true;
938
1100
  log(pc.green(`${tab_a()}✔ Connected to SFTP.`));
939
1101
 
940
- if (!fs.existsSync(CONNECTION.localRoot)) {
1102
+ if (!SKIP_SYNC && !fs.existsSync(CONNECTION.localRoot)) {
941
1103
  console.error(
942
1104
  pc.red("❌ Local root does not exist:"),
943
1105
  CONNECTION.localRoot
@@ -946,7 +1108,7 @@ async function main() {
946
1108
  }
947
1109
 
948
1110
  // -------------------------------------------------------------
949
- // SKIP-SYNC-Modus → nur Bypass mit Listen
1111
+ // SKIP-SYNC-Modus → nur Sidecar-Listen
950
1112
  // -------------------------------------------------------------
951
1113
  if (SKIP_SYNC) {
952
1114
  await performBypassOnly(sftp);
@@ -958,7 +1120,7 @@ async function main() {
958
1120
  }
959
1121
 
960
1122
  // -------------------------------------------------------------
961
- // Normaler Sync (inkl. evtl. paralleler Listen-Excludes)
1123
+ // Normaler Sync (inkl. evtl. paralleler Sidecar-Excludes)
962
1124
  // -------------------------------------------------------------
963
1125
 
964
1126
  // Phase 1 – mit exakt einer Leerzeile davor
@@ -969,7 +1131,7 @@ async function main() {
969
1131
 
970
1132
  if (AUTO_EXCLUDED.size > 0) {
971
1133
  log("");
972
- log(pc.dim(" Auto-excluded (uploadList/downloadList):"));
1134
+ log(pc.dim(" Auto-excluded (sidecar upload/download):"));
973
1135
  [...AUTO_EXCLUDED].sort().forEach((file) => {
974
1136
  log(pc.dim(`${tab_a()} - ${file}`));
975
1137
  });
@@ -1105,6 +1267,20 @@ async function main() {
1105
1267
  log(`${tab_a()}No orphaned remote files found.`);
1106
1268
  }
1107
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
+
1108
1284
  // -------------------------------------------------------------------
1109
1285
  // Phase 5: Execute changes (parallel, worker-based)
1110
1286
  // -------------------------------------------------------------------
@@ -1118,6 +1294,7 @@ async function main() {
1118
1294
  toAdd,
1119
1295
  CONNECTION.workers,
1120
1296
  async ({ local: l, remotePath }) => {
1297
+ // Verzeichnisse sollten bereits existieren – mkdir hier nur als Fallback
1121
1298
  const remoteDir = path.posix.dirname(remotePath);
1122
1299
  try {
1123
1300
  await sftp.mkdir(remoteDir, true);
@@ -1171,6 +1348,13 @@ async function main() {
1171
1348
  );
1172
1349
  }
1173
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
+
1174
1358
  const duration = ((Date.now() - start) / 1000).toFixed(2);
1175
1359
 
1176
1360
  // Write cache safely at the end
@@ -1186,7 +1370,7 @@ async function main() {
1186
1370
  log(`${tab_a()}${DEL} Deleted: ${toDelete.length}`);
1187
1371
  if (AUTO_EXCLUDED.size > 0) {
1188
1372
  log(
1189
- `${tab_a()}${EXC} Excluded via uploadList | downloadList: ${
1373
+ `${tab_a()}${EXC} Excluded via sidecar upload/download: ${
1190
1374
  AUTO_EXCLUDED.size
1191
1375
  }`
1192
1376
  );
@@ -1249,4 +1433,4 @@ async function main() {
1249
1433
  }
1250
1434
  }
1251
1435
 
1252
- main();
1436
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sftp-push-sync",
3
- "version": "1.0.21",
3
+ "version": "2.1.0",
4
4
  "description": "SFTP sync tool for Hugo projects (local to remote, with hash cache)",
5
5
  "type": "module",
6
6
  "bin": {