sftp-push-sync 2.0.0 → 2.1.1

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.0] - 2025-11-19
4
+
5
+ Sync only handles files and creates missing directories during upload.
6
+ However, it should also manage directories:
7
+
8
+ - They should (optionally) be removed if:
9
+ - for example, a directory is empty because all files have been deleted from it.
10
+ - or if a directory no longer exists locally.
11
+
12
+ This is now taken into account with the option: `cleanupEmptyDirs`.
13
+
3
14
  ## [2.0.0] - 2025-11-18
4
15
 
5
16
  ### Breaking
package/README.md CHANGED
@@ -85,6 +85,7 @@ Create a `sync.config.json` in the root folder of your project:
85
85
  }
86
86
  }
87
87
  },
88
+ "cleanupEmptyDirs": true,
88
89
  "include": [],
89
90
  "exclude": ["**/.DS_Store", "**/.git/**", "**/node_modules/**"],
90
91
  "textExtensions": [".html",".xml",".txt",".json",".js",".css",".md",".svg"],
@@ -162,7 +163,8 @@ sftp-push-sync staging
162
163
  # Normal synchronisation + explicitly transfer sidecar upload list
163
164
  sftp-push-sync staging --sidecar-upload
164
165
 
165
- # just fetch the sidecar download list from the server (combined with normal synchronisation)
166
+ # just fetch the sidecar download list from the server
167
+ # combined with normal synchronisation
166
168
  sftp-push-sync prod --sidecar-download --dry-run # view first
167
169
  sftp-push-sync prod --sidecar-download # then do
168
170
  ```
@@ -218,6 +220,15 @@ practical excludes:
218
220
  ]
219
221
  ```
220
222
 
223
+ ### Folder handling
224
+
225
+ Sync only handles files and creates missing directories during upload.
226
+ However, it should also manage directories:
227
+
228
+ - They should (optionally) be removed if:
229
+ - for example, a directory is empty because all files have been deleted from it.
230
+ - or if a directory no longer exists locally.
231
+
221
232
  ## Which files are needed?
222
233
 
223
234
  - `sync.config.json` - The configuration file (with passwords in plain text, so please leave it out of the git repository)
@@ -227,9 +238,9 @@ practical excludes:
227
238
  - The cache files: `.sync-cache.*.json`
228
239
  - The log file: `.sftp-push-sync.{target}.log` (Optional, overwritten with each run)
229
240
 
230
- 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.
231
242
 
232
- 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.
233
244
 
234
245
  ## Example Output
235
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.
@@ -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 --sidecar-upload or --sidecar-download.")
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,6 +212,7 @@ if (!TARGET_CONFIG) {
205
212
  process.exit(1);
206
213
  }
207
214
 
215
+ // Haupt-Sync-Config + Sidecar
208
216
  const SYNC_CFG = TARGET_CONFIG.sync ?? TARGET_CONFIG;
209
217
  const SIDECAR_CFG = TARGET_CONFIG.sidecar ?? {};
210
218
 
@@ -225,10 +233,8 @@ const CONNECTION = {
225
233
  // Main sync roots
226
234
  localRoot: path.resolve(SYNC_CFG.localRoot),
227
235
  remoteRoot: SYNC_CFG.remoteRoot,
228
- // Sidecar roots (for uploadList/downloadList)
229
- sidecarLocalRoot: path.resolve(
230
- SIDECAR_CFG.localRoot ?? SYNC_CFG.localRoot
231
- ),
236
+ // Sidecar roots (für sidecar-upload / sidecar-download)
237
+ sidecarLocalRoot: path.resolve(SIDECAR_CFG.localRoot ?? SYNC_CFG.localRoot),
232
238
  sidecarRemoteRoot: SIDECAR_CFG.remoteRoot ?? SYNC_CFG.remoteRoot,
233
239
  workers: TARGET_CONFIG.worker ?? 2,
234
240
  };
@@ -253,6 +259,10 @@ const SCAN_CHUNK = PROGRESS.scanChunk ?? (IS_VERBOSE ? 1 : 100);
253
259
  const ANALYZE_CHUNK = PROGRESS.analyzeChunk ?? (IS_VERBOSE ? 1 : 10);
254
260
  // For >100k files, rather 10–50, for debugging/troubleshooting 1.
255
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
+
256
266
  // ---------------------------------------------------------------------------
257
267
  // Shared config from JSON
258
268
  // ---------------------------------------------------------------------------
@@ -311,10 +321,10 @@ const DOWNLOAD_LIST = normalizeList(SIDECAR_CFG.downloadList ?? []);
311
321
 
312
322
  // Effektive Exclude-Liste: explizites exclude + Upload/Download-Listen
313
323
  // → diese Dateien werden im „normalen“ Sync nicht angerührt,
314
- // sondern nur über die Bypass-Mechanik behandelt.
324
+ // sondern nur über die Sidecar-Mechanik behandelt.
315
325
  const EXCLUDE = [...BASE_EXCLUDE, ...UPLOAD_LIST, ...DOWNLOAD_LIST];
316
326
 
317
- // List of ALL files that were excluded due to uploadList/downloadList
327
+ // List of ALL files that were ausgeschlossen durch uploadList/downloadList
318
328
  const AUTO_EXCLUDED = new Set();
319
329
 
320
330
  // Cache file name per connection
@@ -403,7 +413,7 @@ function isIncluded(relPath) {
403
413
  if (INCLUDE.length > 0 && !matchesAny(INCLUDE, relPath)) return false;
404
414
  // Exclude-Regeln
405
415
  if (EXCLUDE.length > 0 && matchesAny(EXCLUDE, relPath)) {
406
- // Falls durch Upload/Download-Liste → merken
416
+ // Falls durch Sidecar-Listen → merken
407
417
  if (UPLOAD_LIST.includes(relPath) || DOWNLOAD_LIST.includes(relPath)) {
408
418
  AUTO_EXCLUDED.add(relPath);
409
419
  }
@@ -443,14 +453,12 @@ function shortenPathForProgress(rel) {
443
453
  function updateProgress2(prefix, current, total, rel = "") {
444
454
  const short = rel ? shortenPathForProgress(rel) : "";
445
455
 
446
- //Log file: always as a single line with **full** rel path
456
+ // Log file: always as a single line with **full** rel path
447
457
  const base =
448
458
  total && total > 0
449
459
  ? `${prefix}${current}/${total} Files`
450
460
  : `${prefix}${current} Files`;
451
- writeLogLine(
452
- `[progress] ${base}${rel ? " – " + rel : ""}`
453
- );
461
+ writeLogLine(`[progress] ${base}${rel ? " – " + rel : ""}`);
454
462
 
455
463
  if (!process.stdout.isTTY) {
456
464
  // Fallback-Terminal
@@ -526,6 +534,124 @@ async function runTasks(items, workerCount, handler, label = "Tasks") {
526
534
  await Promise.all(workers);
527
535
  }
528
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
+
529
655
  // ---------------------------------------------------------------------------
530
656
  // Local file walker (recursive, all subdirectories)
531
657
  // ---------------------------------------------------------------------------
@@ -616,7 +742,7 @@ async function walkRemote(sftp, remoteRoot) {
616
742
  const full = path.posix.join(remoteDir, item.name);
617
743
  const rel = prefix ? `${prefix}/${item.name}` : item.name;
618
744
 
619
- // Apply include/exclude rules also on remote side
745
+ // Include/Exclude-Regeln auch auf Remote anwenden
620
746
  if (!isIncluded(rel)) continue;
621
747
 
622
748
  if (item.type === "d") {
@@ -790,7 +916,7 @@ function describeSftpError(err) {
790
916
  }
791
917
 
792
918
  // ---------------------------------------------------------------------------
793
- // Bypass-only Mode (uploadList / downloadList ohne normalen Sync)
919
+ // Bypass-only Mode (sidecar-upload / sidecar-download ohne normalen Sync)
794
920
  // ---------------------------------------------------------------------------
795
921
 
796
922
  async function collectUploadTargets() {
@@ -832,12 +958,8 @@ async function collectDownloadTargets(sftp) {
832
958
  async function performBypassOnly(sftp) {
833
959
  log("");
834
960
  log(pc.bold(pc.cyan("🚀 Bypass-Only Mode (skip-sync)")));
835
- log(
836
- `${tab_a()}Sidecar Local: ${pc.green(CONNECTION.sidecarLocalRoot)}`
837
- );
838
- log(
839
- `${tab_a()}Sidecar Remote: ${pc.green(CONNECTION.sidecarRemoteRoot)}`
840
- );
961
+ log(`${tab_a()}Sidecar Local: ${pc.green(CONNECTION.sidecarLocalRoot)}`);
962
+ log(`${tab_a()}Sidecar Remote: ${pc.green(CONNECTION.sidecarRemoteRoot)}`);
841
963
 
842
964
  if (RUN_UPLOAD_LIST && !fs.existsSync(CONNECTION.sidecarLocalRoot)) {
843
965
  elog(
@@ -849,7 +971,7 @@ async function performBypassOnly(sftp) {
849
971
 
850
972
  if (RUN_UPLOAD_LIST) {
851
973
  log("");
852
- log(pc.bold(pc.cyan("⬆️ Upload-Bypass (uploadList) …")));
974
+ log(pc.bold(pc.cyan("⬆️ Upload-Bypass (sidecar-upload) …")));
853
975
  const targets = await collectUploadTargets();
854
976
  log(`${tab_a()}→ ${targets.length} files from uploadList`);
855
977
 
@@ -878,7 +1000,7 @@ async function performBypassOnly(sftp) {
878
1000
 
879
1001
  if (RUN_DOWNLOAD_LIST) {
880
1002
  log("");
881
- log(pc.bold(pc.cyan("⬇️ Download-Bypass (downloadList) …")));
1003
+ log(pc.bold(pc.cyan("⬇️ Download-Bypass (sidecar-download) …")));
882
1004
  const targets = await collectDownloadTargets(sftp);
883
1005
  log(`${tab_a()}→ ${targets.length} files from downloadList`);
884
1006
 
@@ -936,24 +1058,23 @@ async function main() {
936
1058
  log(`${tab_a()}Local: ${pc.green(CONNECTION.localRoot)}`);
937
1059
  log(`${tab_a()}Remote: ${pc.green(CONNECTION.remoteRoot)}`);
938
1060
  if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST || SKIP_SYNC) {
939
- log(
940
- `${tab_a()}Sidecar Local: ${pc.green(CONNECTION.sidecarLocalRoot)}`
941
- );
942
- log(
943
- `${tab_a()}Sidecar Remote: ${pc.green(CONNECTION.sidecarRemoteRoot)}`
944
- );
1061
+ log(`${tab_a()}Sidecar Local: ${pc.green(CONNECTION.sidecarLocalRoot)}`);
1062
+ log(`${tab_a()}Sidecar Remote: ${pc.green(CONNECTION.sidecarRemoteRoot)}`);
945
1063
  }
946
1064
  if (DRY_RUN) log(pc.yellow(`${tab_a()}Mode: DRY-RUN (no changes)`));
947
1065
  if (SKIP_SYNC) log(pc.yellow(`${tab_a()}Mode: SKIP-SYNC (bypass only)`));
948
1066
  if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST) {
949
1067
  log(
950
1068
  pc.blue(
951
- `${tab_a()}Extra: ${RUN_UPLOAD_LIST ? "uploadList " : ""}${
952
- RUN_DOWNLOAD_LIST ? "downloadList" : ""
953
- }`
1069
+ `${tab_a()}Extra: ${
1070
+ RUN_UPLOAD_LIST ? "sidecar-upload " : ""
1071
+ }${RUN_DOWNLOAD_LIST ? "sidecar-download" : ""}`
954
1072
  )
955
1073
  );
956
1074
  }
1075
+ if (CLEANUP_EMPTY_DIRS) {
1076
+ log(`${tab_a()}Cleanup empty dirs: ${pc.green("enabled")}`);
1077
+ }
957
1078
  if (LOG_FILE) {
958
1079
  log(`${tab_a()}LogFile: ${pc.cyan(LOG_FILE)}`);
959
1080
  }
@@ -987,7 +1108,7 @@ async function main() {
987
1108
  }
988
1109
 
989
1110
  // -------------------------------------------------------------
990
- // SKIP-SYNC-Modus → nur Bypass mit Listen
1111
+ // SKIP-SYNC-Modus → nur Sidecar-Listen
991
1112
  // -------------------------------------------------------------
992
1113
  if (SKIP_SYNC) {
993
1114
  await performBypassOnly(sftp);
@@ -999,7 +1120,7 @@ async function main() {
999
1120
  }
1000
1121
 
1001
1122
  // -------------------------------------------------------------
1002
- // Normaler Sync (inkl. evtl. paralleler Listen-Excludes)
1123
+ // Normaler Sync (inkl. evtl. paralleler Sidecar-Excludes)
1003
1124
  // -------------------------------------------------------------
1004
1125
 
1005
1126
  // Phase 1 – mit exakt einer Leerzeile davor
@@ -1010,7 +1131,7 @@ async function main() {
1010
1131
 
1011
1132
  if (AUTO_EXCLUDED.size > 0) {
1012
1133
  log("");
1013
- log(pc.dim(" Auto-excluded (uploadList/downloadList):"));
1134
+ log(pc.dim(" Auto-excluded (sidecar upload/download):"));
1014
1135
  [...AUTO_EXCLUDED].sort().forEach((file) => {
1015
1136
  log(pc.dim(`${tab_a()} - ${file}`));
1016
1137
  });
@@ -1146,6 +1267,20 @@ async function main() {
1146
1267
  log(`${tab_a()}No orphaned remote files found.`);
1147
1268
  }
1148
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
+
1149
1284
  // -------------------------------------------------------------------
1150
1285
  // Phase 5: Execute changes (parallel, worker-based)
1151
1286
  // -------------------------------------------------------------------
@@ -1159,6 +1294,7 @@ async function main() {
1159
1294
  toAdd,
1160
1295
  CONNECTION.workers,
1161
1296
  async ({ local: l, remotePath }) => {
1297
+ // Verzeichnisse sollten bereits existieren – mkdir hier nur als Fallback
1162
1298
  const remoteDir = path.posix.dirname(remotePath);
1163
1299
  try {
1164
1300
  await sftp.mkdir(remoteDir, true);
@@ -1212,6 +1348,13 @@ async function main() {
1212
1348
  );
1213
1349
  }
1214
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
+
1215
1358
  const duration = ((Date.now() - start) / 1000).toFixed(2);
1216
1359
 
1217
1360
  // Write cache safely at the end
@@ -1227,7 +1370,7 @@ async function main() {
1227
1370
  log(`${tab_a()}${DEL} Deleted: ${toDelete.length}`);
1228
1371
  if (AUTO_EXCLUDED.size > 0) {
1229
1372
  log(
1230
- `${tab_a()}${EXC} Excluded via uploadList | downloadList: ${
1373
+ `${tab_a()}${EXC} Excluded via sidecar upload/download: ${
1231
1374
  AUTO_EXCLUDED.size
1232
1375
  }`
1233
1376
  );
@@ -1290,4 +1433,4 @@ async function main() {
1290
1433
  }
1291
1434
  }
1292
1435
 
1293
- main();
1436
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sftp-push-sync",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "SFTP sync tool for Hugo projects (local to remote, with hash cache)",
5
5
  "type": "module",
6
6
  "bin": {