sftp-push-sync 1.0.6 → 1.0.8
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 +30 -0
- package/bin/sftp-push-sync.mjs +116 -25
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -64,10 +64,40 @@ Create a `sync.config.json` in the root folder of your project:
|
|
|
64
64
|
],
|
|
65
65
|
"textExtensions": [
|
|
66
66
|
".html", ".xml", ".txt", ".json", ".js", ".css", ".md", ".svg"
|
|
67
|
+
],
|
|
68
|
+
"uploadList": [
|
|
69
|
+
"download-files.json"
|
|
70
|
+
],
|
|
71
|
+
"downloadList": [
|
|
72
|
+
"download-counter.json"
|
|
67
73
|
]
|
|
68
74
|
}
|
|
69
75
|
```
|
|
70
76
|
|
|
77
|
+
### special cases
|
|
78
|
+
|
|
79
|
+
- uploadList
|
|
80
|
+
- Relativ zu localRoot "download-files.json"
|
|
81
|
+
- oder mit Unterordnern: "data/download-files.json"
|
|
82
|
+
- downloadList
|
|
83
|
+
- Relativ zu remoteRoot "download-counter.json"
|
|
84
|
+
- oder zB. "logs/download-counter.json"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# normal synchronisation
|
|
89
|
+
sftp-push-sync staging
|
|
90
|
+
|
|
91
|
+
# Normal synchronisation + explicitly transfer upload list
|
|
92
|
+
sftp-push-sync staging --upload-list
|
|
93
|
+
|
|
94
|
+
# just fetch the download list from the server (combined with normal synchronisation)
|
|
95
|
+
sftp-push-sync prod --download-list --dry-run # erst ansehen
|
|
96
|
+
sftp-push-sync prod --download-list # dann machen
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## NPM Scripts
|
|
100
|
+
|
|
71
101
|
- Can be conveniently started via the scripts in `package.json`:
|
|
72
102
|
|
|
73
103
|
```bash
|
package/bin/sftp-push-sync.mjs
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
* - Parallel uploads/deletes via worker pool
|
|
21
21
|
* - include/exclude patterns
|
|
22
22
|
*
|
|
23
|
+
* Special cases:
|
|
24
|
+
* - Files can be excluded from synchronisation.
|
|
25
|
+
* - For example, log files or other special files.
|
|
26
|
+
* - These files can be downloaded or uploaded separately.
|
|
27
|
+
*
|
|
28
|
+
*
|
|
23
29
|
* The file sftp-push-sync.mjs is pure JavaScript (ESM), not TypeScript.
|
|
24
30
|
* Node.js can execute it directly as long as "type": "module" is specified in package.json
|
|
25
31
|
* or the file has the extension .mjs.
|
|
@@ -34,10 +40,10 @@ import { createHash } from "crypto";
|
|
|
34
40
|
import { Writable } from "stream";
|
|
35
41
|
import pc from "picocolors";
|
|
36
42
|
|
|
37
|
-
// Colors for the State
|
|
43
|
+
// Colors for the State (works on dark + light background)
|
|
38
44
|
const ADD = pc.green("+"); // Added
|
|
39
45
|
const CHA = pc.yellow("~"); // Changed
|
|
40
|
-
const DEL = pc.red("-"); //
|
|
46
|
+
const DEL = pc.red("-"); // Deleted
|
|
41
47
|
|
|
42
48
|
// ---------------------------------------------------------------------------
|
|
43
49
|
// CLI arguments
|
|
@@ -47,10 +53,12 @@ const args = process.argv.slice(2);
|
|
|
47
53
|
const TARGET = args[0];
|
|
48
54
|
const DRY_RUN = args.includes("--dry-run");
|
|
49
55
|
const VERBOSE = args.includes("--verbose") || args.includes("-v");
|
|
56
|
+
const RUN_UPLOAD_LIST = args.includes("--upload-list");
|
|
57
|
+
const RUN_DOWNLOAD_LIST = args.includes("--download-list");
|
|
50
58
|
|
|
51
59
|
if (!TARGET) {
|
|
52
60
|
console.error(pc.red("❌ Please specify a connection profile:"));
|
|
53
|
-
console.error(pc.yellow("
|
|
61
|
+
console.error(pc.yellow(" sftp-push-sync staging --dry-run"));
|
|
54
62
|
process.exit(1);
|
|
55
63
|
}
|
|
56
64
|
|
|
@@ -111,6 +119,10 @@ const TEXT_EXT = CONFIG_RAW.textExtensions ?? [
|
|
|
111
119
|
".svg",
|
|
112
120
|
];
|
|
113
121
|
|
|
122
|
+
// SPECIAL LISTS
|
|
123
|
+
const UPLOAD_LIST = CONFIG_RAW.uploadList ?? [];
|
|
124
|
+
const DOWNLOAD_LIST = CONFIG_RAW.downloadList ?? [];
|
|
125
|
+
|
|
114
126
|
// Cache file name per connection
|
|
115
127
|
const syncCacheName =
|
|
116
128
|
TARGET_CONFIG.syncCache || `.sync-cache.${TARGET}.json`;
|
|
@@ -122,7 +134,7 @@ const CACHE_PATH = path.resolve(syncCacheName);
|
|
|
122
134
|
|
|
123
135
|
let CACHE = {
|
|
124
136
|
version: 1,
|
|
125
|
-
local: {},
|
|
137
|
+
local: {}, // key: "<TARGET>:<relPath>" -> { size, mtimeMs, hash }
|
|
126
138
|
remote: {}, // key: "<TARGET>:<relPath>" -> { size, modifyTime, hash }
|
|
127
139
|
};
|
|
128
140
|
|
|
@@ -134,7 +146,7 @@ try {
|
|
|
134
146
|
CACHE.remote = raw.remote ?? {};
|
|
135
147
|
}
|
|
136
148
|
} catch (err) {
|
|
137
|
-
wlog(pc.yellow("⚠️
|
|
149
|
+
wlog(pc.yellow("⚠️ Could not load cache, starting without:"), err.message);
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
function cacheKey(relPath) {
|
|
@@ -224,7 +236,7 @@ function updateProgress(prefix, current, total) {
|
|
|
224
236
|
const msg = `${prefix}${current}/${total} Dateien (${percent}%)`;
|
|
225
237
|
|
|
226
238
|
if (!process.stdout.isTTY) {
|
|
227
|
-
// Fallback: simply log
|
|
239
|
+
// Fallback: simply log
|
|
228
240
|
console.log(" " + msg);
|
|
229
241
|
return;
|
|
230
242
|
}
|
|
@@ -243,7 +255,7 @@ function updateProgress(prefix, current, total) {
|
|
|
243
255
|
|
|
244
256
|
// Simple worker pool for parallel tasks
|
|
245
257
|
async function runTasks(items, workerCount, handler, label = "Tasks") {
|
|
246
|
-
if (items.length === 0) return;
|
|
258
|
+
if (!items || items.length === 0) return;
|
|
247
259
|
|
|
248
260
|
const total = items.length;
|
|
249
261
|
let done = 0;
|
|
@@ -259,7 +271,7 @@ async function runTasks(items, workerCount, handler, label = "Tasks") {
|
|
|
259
271
|
try {
|
|
260
272
|
await handler(item);
|
|
261
273
|
} catch (err) {
|
|
262
|
-
elog(pc.red(` ⚠️
|
|
274
|
+
elog(pc.red(` ⚠️ Error in ${label}:`), err.message || err);
|
|
263
275
|
}
|
|
264
276
|
done += 1;
|
|
265
277
|
if (done % 10 === 0 || done === total) {
|
|
@@ -309,7 +321,7 @@ async function walkLocal(root) {
|
|
|
309
321
|
}
|
|
310
322
|
|
|
311
323
|
// ---------------------------------------------------------------------------
|
|
312
|
-
// Remote walker (recursive, all subdirectories)
|
|
324
|
+
// Remote walker (recursive, all subdirectories) – respects INCLUDE/EXCLUDE
|
|
313
325
|
// ---------------------------------------------------------------------------
|
|
314
326
|
|
|
315
327
|
async function walkRemote(sftp, remoteRoot) {
|
|
@@ -324,6 +336,9 @@ async function walkRemote(sftp, remoteRoot) {
|
|
|
324
336
|
const full = path.posix.join(remoteDir, item.name);
|
|
325
337
|
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
326
338
|
|
|
339
|
+
// Apply include/exclude rules also on remote side
|
|
340
|
+
if (!isIncluded(rel)) continue;
|
|
341
|
+
|
|
327
342
|
if (item.type === "d") {
|
|
328
343
|
await recurse(full, rel);
|
|
329
344
|
} else {
|
|
@@ -342,7 +357,7 @@ async function walkRemote(sftp, remoteRoot) {
|
|
|
342
357
|
}
|
|
343
358
|
|
|
344
359
|
// ---------------------------------------------------------------------------
|
|
345
|
-
// Hash helper for binaries (streaming, memory-efficient)
|
|
360
|
+
// Hash helper for binaries (streaming, memory-efficient)
|
|
346
361
|
// ---------------------------------------------------------------------------
|
|
347
362
|
|
|
348
363
|
function hashLocalFile(filePath) {
|
|
@@ -424,10 +439,19 @@ async function main() {
|
|
|
424
439
|
log("\n\n==================================================================");
|
|
425
440
|
log(pc.bold("🔐 SFTP Push-Synchronisation: sftp-push-sync"));
|
|
426
441
|
log(` Connection: ${pc.cyan(TARGET)} (Worker: ${CONNECTION.workers})`);
|
|
427
|
-
log(` Host:
|
|
428
|
-
log(` Local:
|
|
442
|
+
log(` Host: ${pc.green(CONNECTION.host)}:${pc.green(CONNECTION.port)}`);
|
|
443
|
+
log(` Local: ${pc.green(CONNECTION.localRoot)}`);
|
|
429
444
|
log(` Remote: ${pc.green(CONNECTION.remoteRoot)}`);
|
|
430
|
-
if (DRY_RUN) log(pc.yellow("
|
|
445
|
+
if (DRY_RUN) log(pc.yellow(" Mode: DRY-RUN (no changes)"));
|
|
446
|
+
if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST) {
|
|
447
|
+
log(
|
|
448
|
+
pc.blue(
|
|
449
|
+
` Extra: ${RUN_UPLOAD_LIST ? "uploadList " : ""}${
|
|
450
|
+
RUN_DOWNLOAD_LIST ? "downloadList" : ""
|
|
451
|
+
}`
|
|
452
|
+
)
|
|
453
|
+
);
|
|
454
|
+
}
|
|
431
455
|
log("-----------------------------------------------------------------\n");
|
|
432
456
|
|
|
433
457
|
const sftp = new SftpClient();
|
|
@@ -457,7 +481,7 @@ async function main() {
|
|
|
457
481
|
|
|
458
482
|
log(pc.bold(pc.cyan("📤 Phase 2: Scan remote files …")));
|
|
459
483
|
const remote = await walkRemote(sftp, CONNECTION.remoteRoot);
|
|
460
|
-
log(` → ${remote.size} remote files`);
|
|
484
|
+
log(` → ${remote.size} remote files\n`);
|
|
461
485
|
|
|
462
486
|
const localKeys = new Set(local.keys());
|
|
463
487
|
const remoteKeys = new Set(remote.keys());
|
|
@@ -487,7 +511,7 @@ async function main() {
|
|
|
487
511
|
// 1. size comparison
|
|
488
512
|
if (l.size !== r.size) {
|
|
489
513
|
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
490
|
-
log(`${
|
|
514
|
+
log(`${CHA} ${pc.yellow("Size changed:")} ${rel}`);
|
|
491
515
|
continue;
|
|
492
516
|
}
|
|
493
517
|
|
|
@@ -513,11 +537,11 @@ async function main() {
|
|
|
513
537
|
if (VERBOSE) {
|
|
514
538
|
const diff = diffWords(remoteStr, localStr);
|
|
515
539
|
const blocks = diff.filter((d) => d.added || d.removed).length;
|
|
516
|
-
vlog(` ${
|
|
540
|
+
vlog(` ${CHA} Text difference (${blocks} blocks) in ${rel}`);
|
|
517
541
|
}
|
|
518
542
|
|
|
519
543
|
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
520
|
-
log(`${
|
|
544
|
+
log(`${CHA} ${pc.yellow("Content changed (Text):")} ${rel}`);
|
|
521
545
|
} else {
|
|
522
546
|
// Binary: Hash comparison with cache
|
|
523
547
|
const localMeta = l;
|
|
@@ -534,13 +558,13 @@ async function main() {
|
|
|
534
558
|
}
|
|
535
559
|
|
|
536
560
|
if (VERBOSE) {
|
|
537
|
-
vlog(` ${CHA} Hash different(binary): ${rel}`);
|
|
561
|
+
vlog(` ${CHA} Hash different (binary): ${rel}`);
|
|
538
562
|
vlog(` local: ${localHash}`);
|
|
539
563
|
vlog(` remote: ${remoteHash}`);
|
|
540
564
|
}
|
|
541
565
|
|
|
542
566
|
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
543
|
-
log(`${
|
|
567
|
+
log(`${CHA} ${pc.yellow("Content changed (Binary):")} ${rel}`);
|
|
544
568
|
}
|
|
545
569
|
}
|
|
546
570
|
|
|
@@ -555,16 +579,16 @@ async function main() {
|
|
|
555
579
|
|
|
556
580
|
// -------------------------------------------------------------------
|
|
557
581
|
// Phase 5: Execute changes (parallel, worker-based)
|
|
558
|
-
|
|
582
|
+
// -------------------------------------------------------------------
|
|
559
583
|
|
|
560
584
|
if (!DRY_RUN) {
|
|
561
|
-
log("\n" + pc.bold(pc.cyan("🚚 Phase 5:
|
|
585
|
+
log("\n" + pc.bold(pc.cyan("🚚 Phase 5: Apply changes …")));
|
|
562
586
|
|
|
563
587
|
// Upload new files
|
|
564
588
|
await runTasks(
|
|
565
589
|
toAdd,
|
|
566
590
|
CONNECTION.workers,
|
|
567
|
-
async ({
|
|
591
|
+
async ({ local: l, remotePath }) => {
|
|
568
592
|
const remoteDir = path.posix.dirname(remotePath);
|
|
569
593
|
try {
|
|
570
594
|
await sftp.mkdir(remoteDir, true);
|
|
@@ -580,7 +604,7 @@ async function main() {
|
|
|
580
604
|
await runTasks(
|
|
581
605
|
toUpdate,
|
|
582
606
|
CONNECTION.workers,
|
|
583
|
-
async ({
|
|
607
|
+
async ({ local: l, remotePath }) => {
|
|
584
608
|
const remoteDir = path.posix.dirname(remotePath);
|
|
585
609
|
try {
|
|
586
610
|
await sftp.mkdir(remoteDir, true);
|
|
@@ -613,9 +637,76 @@ async function main() {
|
|
|
613
637
|
log(pc.yellow("\n💡 DRY-RUN: No files transferred or deleted."));
|
|
614
638
|
}
|
|
615
639
|
|
|
640
|
+
// -------------------------------------------------------------------
|
|
641
|
+
// Phase 6: optional uploadList / downloadList
|
|
642
|
+
// -------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
if (RUN_UPLOAD_LIST && UPLOAD_LIST.length > 0) {
|
|
645
|
+
log(
|
|
646
|
+
"\n" + pc.bold(pc.cyan("⬆️ Extra Phase: Upload-List (explicit files) …"))
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
const tasks = UPLOAD_LIST.map((rel) => ({
|
|
650
|
+
rel,
|
|
651
|
+
localPath: path.join(CONNECTION.localRoot, rel),
|
|
652
|
+
remotePath: path.posix.join(CONNECTION.remoteRoot, toPosix(rel)),
|
|
653
|
+
}));
|
|
654
|
+
|
|
655
|
+
if (DRY_RUN) {
|
|
656
|
+
for (const t of tasks) {
|
|
657
|
+
log(` ${ADD} would upload (uploadList): ${t.rel}`);
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
await runTasks(
|
|
661
|
+
tasks,
|
|
662
|
+
CONNECTION.workers,
|
|
663
|
+
async ({ localPath, remotePath, rel }) => {
|
|
664
|
+
const remoteDir = path.posix.dirname(remotePath);
|
|
665
|
+
try {
|
|
666
|
+
await sftp.mkdir(remoteDir, true);
|
|
667
|
+
} catch {
|
|
668
|
+
// ignore
|
|
669
|
+
}
|
|
670
|
+
await sftp.put(localPath, remotePath);
|
|
671
|
+
log(` ${ADD} uploadList: ${rel}`);
|
|
672
|
+
},
|
|
673
|
+
"Upload-List"
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (RUN_DOWNLOAD_LIST && DOWNLOAD_LIST.length > 0) {
|
|
679
|
+
log(
|
|
680
|
+
"\n" + pc.bold(pc.cyan("⬇️ Extra Phase: Download-List (explicit files) …"))
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
const tasks = DOWNLOAD_LIST.map((rel) => ({
|
|
684
|
+
rel,
|
|
685
|
+
remotePath: path.posix.join(CONNECTION.remoteRoot, toPosix(rel)),
|
|
686
|
+
localPath: path.join(CONNECTION.localRoot, rel),
|
|
687
|
+
}));
|
|
688
|
+
|
|
689
|
+
if (DRY_RUN) {
|
|
690
|
+
for (const t of tasks) {
|
|
691
|
+
log(` ${ADD} would download (downloadList): ${t.rel}`);
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
await runTasks(
|
|
695
|
+
tasks,
|
|
696
|
+
CONNECTION.workers,
|
|
697
|
+
async ({ remotePath, localPath, rel }) => {
|
|
698
|
+
await fsp.mkdir(path.dirname(localPath), { recursive: true });
|
|
699
|
+
await sftp.fastGet(remotePath, localPath);
|
|
700
|
+
log(` ${ADD} downloadList: ${rel}`);
|
|
701
|
+
},
|
|
702
|
+
"Download-List"
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
616
707
|
const duration = ((Date.now() - start) / 1000).toFixed(2);
|
|
617
708
|
|
|
618
|
-
// Write cache
|
|
709
|
+
// Write cache safely at the end
|
|
619
710
|
await saveCache(true);
|
|
620
711
|
|
|
621
712
|
// Summary
|
|
@@ -635,6 +726,7 @@ async function main() {
|
|
|
635
726
|
}
|
|
636
727
|
|
|
637
728
|
log("\n" + pc.bold(pc.green("✅ Sync complete.")));
|
|
729
|
+
log("==================================================================\n\n");
|
|
638
730
|
} catch (err) {
|
|
639
731
|
elog(pc.red("❌ Synchronisation error:"), err);
|
|
640
732
|
process.exitCode = 1;
|
|
@@ -646,7 +738,6 @@ async function main() {
|
|
|
646
738
|
} finally {
|
|
647
739
|
try {
|
|
648
740
|
await sftp.end();
|
|
649
|
-
log("==================================================================\n\n");
|
|
650
741
|
} catch {
|
|
651
742
|
// ignore
|
|
652
743
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sftp-push-sync",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "SFTP sync tool for Hugo projects (local to remote, with hash cache)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"sync"
|
|
20
20
|
],
|
|
21
21
|
"author": "Carsten Nichte",
|
|
22
|
-
"license": "
|
|
22
|
+
"license": "GPL-3.0-or-later",
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"ssh2-sftp-client": "^10.0.0",
|
|
25
25
|
"minimatch": "^9.0.3",
|