sftp-push-sync 2.1.3 → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/bin/sftp-push-sync.mjs +126 -1443
- package/directory-structure.txt +14 -0
- package/package.json +2 -2
- package/src/core/ScanProgressController.mjs +124 -0
- package/src/core/SftpPushSyncApp.mjs +1060 -0
- package/src/core/SyncLogger.mjs +52 -0
- package/src/helpers/compare.mjs +122 -0
- package/src/helpers/directory.mjs +29 -0
- package/src/helpers/hashing.mjs +201 -0
- package/src/helpers/progress-constants.mjs +28 -0
- package/src/helpers/sidecar.mjs +185 -0
- package/src/helpers/walkers.mjs +218 -0
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SftpPushSyncApp.mjs
|
|
3
|
+
*
|
|
4
|
+
* @author Carsten Nichte, 2025 / https://carsten-nichte.de/
|
|
5
|
+
*
|
|
6
|
+
*/
|
|
7
|
+
// src/core/SftpPushSyncApp.mjs
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import fsp from "fs/promises";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import SftpClient from "ssh2-sftp-client";
|
|
12
|
+
import { minimatch } from "minimatch";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
import { createRequire } from "module";
|
|
15
|
+
|
|
16
|
+
import { SyncLogger } from "./SyncLogger.mjs";
|
|
17
|
+
import { ScanProgressController } from "./ScanProgressController.mjs";
|
|
18
|
+
|
|
19
|
+
import { toPosix, shortenPathForProgress } from "../helpers/directory.mjs";
|
|
20
|
+
import { createHashCache } from "../helpers/hashing.mjs";
|
|
21
|
+
import { walkLocal, walkRemote } from "../helpers/walkers.mjs";
|
|
22
|
+
import {
|
|
23
|
+
analyseDifferences,
|
|
24
|
+
computeRemoteDeletes,
|
|
25
|
+
} from "../helpers/compare.mjs";
|
|
26
|
+
import { performBypassOnly as performSidecarBypass } from "../helpers/sidecar.mjs";
|
|
27
|
+
import {
|
|
28
|
+
hr1,
|
|
29
|
+
hr2,
|
|
30
|
+
TAB_A,
|
|
31
|
+
TAB_B,
|
|
32
|
+
SPINNER_FRAMES,
|
|
33
|
+
} from "../helpers/progress-constants.mjs";
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const pkg = require("../../package.json");
|
|
37
|
+
|
|
38
|
+
// Symbole & Format
|
|
39
|
+
const ADD = pc.green("+");
|
|
40
|
+
const CHA = pc.yellow("~");
|
|
41
|
+
const DEL = pc.red("-");
|
|
42
|
+
const EXC = pc.redBright("-");
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Fehlerhilfe SFTP
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
function describeSftpError(err) {
|
|
48
|
+
if (!err) return "";
|
|
49
|
+
|
|
50
|
+
const code = err.code || err.errno || "";
|
|
51
|
+
const msg = (err.message || "").toLowerCase();
|
|
52
|
+
|
|
53
|
+
if (code === "ENOTFOUND") {
|
|
54
|
+
return "Host not found (ENOTFOUND) – Check hostname or DNS entry.";
|
|
55
|
+
}
|
|
56
|
+
if (code === "EHOSTUNREACH") {
|
|
57
|
+
return "Host not reachable (EHOSTUNREACH) – Check network/firewall.";
|
|
58
|
+
}
|
|
59
|
+
if (code === "ECONNREFUSED") {
|
|
60
|
+
return "Connection refused (ECONNREFUSED) – Check the port or SSH service.";
|
|
61
|
+
}
|
|
62
|
+
if (code === "ECONNRESET") {
|
|
63
|
+
return "Connection was reset by the server (ECONNRESET).";
|
|
64
|
+
}
|
|
65
|
+
if (code === "ETIMEDOUT") {
|
|
66
|
+
return "Connection timeout (ETIMEDOUT) – Server is not responding or is blocked.";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (msg.includes("all configured authentication methods failed")) {
|
|
70
|
+
return "Authentication failed – check your username/password or SSH keys.";
|
|
71
|
+
}
|
|
72
|
+
if (msg.includes("permission denied")) {
|
|
73
|
+
return "Access denied – check permissions on the server.";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// App-Klasse
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export class SftpPushSyncApp {
|
|
84
|
+
/**
|
|
85
|
+
* options: {
|
|
86
|
+
* target,
|
|
87
|
+
* dryRun,
|
|
88
|
+
* runUploadList,
|
|
89
|
+
* runDownloadList,
|
|
90
|
+
* skipSync,
|
|
91
|
+
* cliLogLevel,
|
|
92
|
+
* configPath
|
|
93
|
+
* }
|
|
94
|
+
*/
|
|
95
|
+
constructor(options = {}) {
|
|
96
|
+
this.options = options;
|
|
97
|
+
|
|
98
|
+
// Konfiguration
|
|
99
|
+
this.configRaw = null;
|
|
100
|
+
this.targetConfig = null;
|
|
101
|
+
this.connection = null;
|
|
102
|
+
|
|
103
|
+
// Patterns
|
|
104
|
+
this.includePatterns = [];
|
|
105
|
+
this.baseExcludePatterns = [];
|
|
106
|
+
this.uploadList = [];
|
|
107
|
+
this.downloadList = [];
|
|
108
|
+
this.excludePatterns = [];
|
|
109
|
+
this.autoExcluded = new Set();
|
|
110
|
+
|
|
111
|
+
// Dateitypen
|
|
112
|
+
this.textExt = [];
|
|
113
|
+
this.mediaExt = [];
|
|
114
|
+
|
|
115
|
+
// Log / Level
|
|
116
|
+
this.logLevel = "normal";
|
|
117
|
+
this.isVerbose = false;
|
|
118
|
+
this.isLaconic = false;
|
|
119
|
+
this.logger = null;
|
|
120
|
+
|
|
121
|
+
// Progress
|
|
122
|
+
this.scanChunk = 100;
|
|
123
|
+
this.analyzeChunk = 10;
|
|
124
|
+
this.parallelScan = true;
|
|
125
|
+
|
|
126
|
+
this.progressActive = false;
|
|
127
|
+
this.spinnerIndex = 0;
|
|
128
|
+
|
|
129
|
+
// Cleanup
|
|
130
|
+
this.cleanupEmptyDirsEnabled = true;
|
|
131
|
+
this.cleanupEmptyRoots = false;
|
|
132
|
+
this.dirStats = {
|
|
133
|
+
ensuredDirs: 0,
|
|
134
|
+
createdDirs: 0,
|
|
135
|
+
cleanupVisited: 0,
|
|
136
|
+
cleanupDeleted: 0,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Cache
|
|
140
|
+
this.hashCache = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------
|
|
144
|
+
// Logging-Helfer (Console + Logfile)
|
|
145
|
+
// ---------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
_writeLogFile(line) {
|
|
148
|
+
if (this.logger) {
|
|
149
|
+
this.logger.writeLine(line);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_clearProgressLine() {
|
|
154
|
+
if (!process.stdout.isTTY || !this.progressActive) return;
|
|
155
|
+
|
|
156
|
+
process.stdout.write("\r");
|
|
157
|
+
process.stdout.write("\x1b[2K");
|
|
158
|
+
process.stdout.write("\x1b[1B");
|
|
159
|
+
process.stdout.write("\x1b[2K");
|
|
160
|
+
process.stdout.write("\x1b[1A");
|
|
161
|
+
|
|
162
|
+
this.progressActive = false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_consoleAndLog(prefixForFile, ...msg) {
|
|
166
|
+
this._clearProgressLine();
|
|
167
|
+
console.log(...msg);
|
|
168
|
+
const line = msg
|
|
169
|
+
.map((m) => (typeof m === "string" ? m : String(m)))
|
|
170
|
+
.join(" ");
|
|
171
|
+
this._writeLogFile(prefixForFile ? prefixForFile + line : line);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
log(...msg) {
|
|
175
|
+
this._consoleAndLog("", ...msg);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
elog(...msg) {
|
|
179
|
+
this._consoleAndLog("[ERROR] ", ...msg);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
wlog(...msg) {
|
|
183
|
+
this._consoleAndLog("[WARN] ", ...msg);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
vlog(...msg) {
|
|
187
|
+
if (!this.isVerbose) return;
|
|
188
|
+
this._consoleAndLog("", ...msg);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------
|
|
192
|
+
// Pattern-Helper
|
|
193
|
+
// ---------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
matchesAny(patterns, relPath) {
|
|
196
|
+
if (!patterns || patterns.length === 0) return false;
|
|
197
|
+
return patterns.some((pattern) =>
|
|
198
|
+
minimatch(relPath, pattern, { dot: true })
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
isIncluded(relPath) {
|
|
203
|
+
if (
|
|
204
|
+
this.includePatterns.length > 0 &&
|
|
205
|
+
!this.matchesAny(this.includePatterns, relPath)
|
|
206
|
+
) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
this.excludePatterns.length > 0 &&
|
|
212
|
+
this.matchesAny(this.excludePatterns, relPath)
|
|
213
|
+
) {
|
|
214
|
+
if (
|
|
215
|
+
this.uploadList.includes(relPath) ||
|
|
216
|
+
this.downloadList.includes(relPath)
|
|
217
|
+
) {
|
|
218
|
+
this.autoExcluded.add(relPath);
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
isTextFile(relPath) {
|
|
227
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
228
|
+
return this.textExt.includes(ext);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
isMediaFile(relPath) {
|
|
232
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
233
|
+
return this.mediaExt.includes(ext);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------
|
|
237
|
+
// Progress-Balken (Phase 3, Verzeichnisse, Cleanup)
|
|
238
|
+
// ---------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
updateProgress2(prefix, current, total, rel = "", suffix = "Files") {
|
|
241
|
+
const short = rel ? shortenPathForProgress(rel) : "";
|
|
242
|
+
|
|
243
|
+
const base =
|
|
244
|
+
total && total > 0
|
|
245
|
+
? `${prefix}${current}/${total} ${suffix}`
|
|
246
|
+
: `${prefix}${current} ${suffix}`;
|
|
247
|
+
|
|
248
|
+
this._writeLogFile(
|
|
249
|
+
`[progress] ${base}${rel ? " – " + rel : ""}`
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const frame = SPINNER_FRAMES[this.spinnerIndex];
|
|
253
|
+
this.spinnerIndex = (this.spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
254
|
+
|
|
255
|
+
if (!process.stdout.isTTY) {
|
|
256
|
+
if (total && total > 0) {
|
|
257
|
+
const percent = ((current / total) * 100).toFixed(1);
|
|
258
|
+
console.log(
|
|
259
|
+
`${TAB_A}${frame} ${prefix}${current}/${total} ${suffix} (${percent}%) – ${short}`
|
|
260
|
+
);
|
|
261
|
+
} else {
|
|
262
|
+
console.log(
|
|
263
|
+
`${TAB_A}${frame} ${prefix}${current} ${suffix} – ${short}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const width = process.stdout.columns || 80;
|
|
270
|
+
|
|
271
|
+
let line1;
|
|
272
|
+
if (total && total > 0) {
|
|
273
|
+
const percent = ((current / total) * 100).toFixed(1);
|
|
274
|
+
line1 = `${TAB_A}${frame} ${prefix}${current}/${total} ${suffix} (${percent}%)`;
|
|
275
|
+
} else {
|
|
276
|
+
line1 = `${TAB_A}${frame} ${prefix}${current} ${suffix}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let line2 = short || "";
|
|
280
|
+
|
|
281
|
+
if (line1.length > width) line1 = line1.slice(0, width - 1);
|
|
282
|
+
if (line2.length > width) line2 = line2.slice(0, width - 1);
|
|
283
|
+
|
|
284
|
+
process.stdout.write("\r" + line1.padEnd(width) + "\n");
|
|
285
|
+
process.stdout.write(line2.padEnd(width));
|
|
286
|
+
process.stdout.write("\x1b[1A");
|
|
287
|
+
|
|
288
|
+
this.progressActive = true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------
|
|
292
|
+
// Worker-Pool
|
|
293
|
+
// ---------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
async runTasks(items, workerCount, handler, label = "Tasks") {
|
|
296
|
+
if (!items || items.length === 0) return;
|
|
297
|
+
|
|
298
|
+
const total = items.length;
|
|
299
|
+
let done = 0;
|
|
300
|
+
let index = 0;
|
|
301
|
+
const workers = [];
|
|
302
|
+
const actualWorkers = Math.max(1, Math.min(workerCount, total));
|
|
303
|
+
|
|
304
|
+
const worker = async () => {
|
|
305
|
+
// eslint-disable-next-line no-constant-condition
|
|
306
|
+
while (true) {
|
|
307
|
+
const i = index;
|
|
308
|
+
if (i >= total) break;
|
|
309
|
+
index += 1;
|
|
310
|
+
const item = items[i];
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await handler(item);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
this.elog(
|
|
316
|
+
pc.red(`${TAB_A}⚠️ Error in ${label}:`),
|
|
317
|
+
err?.message || err
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
done += 1;
|
|
322
|
+
if (done === 1 || done % 10 === 0 || done === total) {
|
|
323
|
+
this.updateProgress2(`${label}: `, done, total, item.rel ?? "");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
for (let i = 0; i < actualWorkers; i += 1) {
|
|
329
|
+
workers.push(worker());
|
|
330
|
+
}
|
|
331
|
+
await Promise.all(workers);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------
|
|
335
|
+
// Helper: Verzeichnisse vorbereiten
|
|
336
|
+
// ---------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
collectDirsFromChanges(changes) {
|
|
339
|
+
const dirs = new Set();
|
|
340
|
+
|
|
341
|
+
for (const item of changes) {
|
|
342
|
+
const rel = item.rel;
|
|
343
|
+
if (!rel) continue;
|
|
344
|
+
|
|
345
|
+
const parts = rel.split("/");
|
|
346
|
+
if (parts.length <= 1) continue;
|
|
347
|
+
|
|
348
|
+
let acc = "";
|
|
349
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
350
|
+
acc = acc ? `${acc}/${parts[i]}` : parts[i];
|
|
351
|
+
dirs.add(acc);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return [...dirs].sort(
|
|
356
|
+
(a, b) => a.split("/").length - b.split("/").length
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async ensureAllRemoteDirsExist(sftp, remoteRoot, toAdd, toUpdate) {
|
|
361
|
+
const dirs = this.collectDirsFromChanges([...toAdd, ...toUpdate]);
|
|
362
|
+
const total = dirs.length;
|
|
363
|
+
this.dirStats.ensuredDirs += total;
|
|
364
|
+
|
|
365
|
+
if (total === 0) return;
|
|
366
|
+
|
|
367
|
+
let current = 0;
|
|
368
|
+
|
|
369
|
+
for (const relDir of dirs) {
|
|
370
|
+
current += 1;
|
|
371
|
+
const remoteDir = path.posix.join(remoteRoot, relDir);
|
|
372
|
+
|
|
373
|
+
this.updateProgress2(
|
|
374
|
+
"Prepare dirs: ",
|
|
375
|
+
current,
|
|
376
|
+
total,
|
|
377
|
+
relDir,
|
|
378
|
+
"Folders"
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const exists = await sftp.exists(remoteDir);
|
|
383
|
+
if (!exists) {
|
|
384
|
+
await sftp.mkdir(remoteDir, true);
|
|
385
|
+
this.dirStats.createdDirs += 1;
|
|
386
|
+
this.vlog(`${TAB_A}${pc.dim("dir created:")} ${remoteDir}`);
|
|
387
|
+
} else {
|
|
388
|
+
this.vlog(`${TAB_A}${pc.dim("dir ok:")} ${remoteDir}`);
|
|
389
|
+
}
|
|
390
|
+
} catch (e) {
|
|
391
|
+
this.wlog(
|
|
392
|
+
pc.yellow("⚠️ Could not ensure directory:"),
|
|
393
|
+
remoteDir,
|
|
394
|
+
e?.message || e
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this.updateProgress2("Prepare dirs: ", total, total, "done", "Folders");
|
|
400
|
+
process.stdout.write("\n");
|
|
401
|
+
this.progressActive = false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------
|
|
405
|
+
// Cleanup: leere Verzeichnisse löschen
|
|
406
|
+
// ---------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
async cleanupEmptyDirs(sftp, rootDir, dryRun) {
|
|
409
|
+
const recurse = async (dir) => {
|
|
410
|
+
this.dirStats.cleanupVisited += 1;
|
|
411
|
+
|
|
412
|
+
const relForProgress = toPosix(path.relative(rootDir, dir)) || ".";
|
|
413
|
+
|
|
414
|
+
this.updateProgress2(
|
|
415
|
+
"Cleanup dirs: ",
|
|
416
|
+
this.dirStats.cleanupVisited,
|
|
417
|
+
0,
|
|
418
|
+
relForProgress,
|
|
419
|
+
"Folders"
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
let hasFile = false;
|
|
423
|
+
const subdirs = [];
|
|
424
|
+
let items;
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
items = await sftp.list(dir);
|
|
428
|
+
} catch (e) {
|
|
429
|
+
this.wlog(
|
|
430
|
+
pc.yellow("⚠️ Could not list directory during cleanup:"),
|
|
431
|
+
dir,
|
|
432
|
+
e?.message || e
|
|
433
|
+
);
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const item of items) {
|
|
438
|
+
if (!item.name || item.name === "." || item.name === "..") continue;
|
|
439
|
+
if (item.type === "d") {
|
|
440
|
+
subdirs.push(item);
|
|
441
|
+
} else {
|
|
442
|
+
hasFile = true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let allSubdirsEmpty = true;
|
|
447
|
+
for (const sub of subdirs) {
|
|
448
|
+
const full = path.posix.join(dir, sub.name);
|
|
449
|
+
const subEmpty = await recurse(full);
|
|
450
|
+
if (!subEmpty) {
|
|
451
|
+
allSubdirsEmpty = false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const isRoot = dir === rootDir;
|
|
456
|
+
const isEmpty = !hasFile && allSubdirsEmpty;
|
|
457
|
+
|
|
458
|
+
if (isEmpty && (!isRoot || this.cleanupEmptyRoots)) {
|
|
459
|
+
const rel = relForProgress || ".";
|
|
460
|
+
if (dryRun) {
|
|
461
|
+
this.log(
|
|
462
|
+
`${TAB_A}${DEL} (DRY-RUN) Remove empty directory: ${rel}`
|
|
463
|
+
);
|
|
464
|
+
this.dirStats.cleanupDeleted += 1;
|
|
465
|
+
} else {
|
|
466
|
+
try {
|
|
467
|
+
await sftp.rmdir(dir, false);
|
|
468
|
+
this.log(`${TAB_A}${DEL} Removed empty directory: ${rel}`);
|
|
469
|
+
this.dirStats.cleanupDeleted += 1;
|
|
470
|
+
} catch (e) {
|
|
471
|
+
this.wlog(
|
|
472
|
+
pc.yellow("⚠️ Could not remove directory:"),
|
|
473
|
+
dir,
|
|
474
|
+
e?.message || e
|
|
475
|
+
);
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return isEmpty;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
await recurse(rootDir);
|
|
485
|
+
|
|
486
|
+
if (this.dirStats.cleanupVisited > 0) {
|
|
487
|
+
this.updateProgress2(
|
|
488
|
+
"Cleanup dirs: ",
|
|
489
|
+
this.dirStats.cleanupVisited,
|
|
490
|
+
this.dirStats.cleanupVisited,
|
|
491
|
+
"done",
|
|
492
|
+
"Folders"
|
|
493
|
+
);
|
|
494
|
+
process.stdout.write("\n");
|
|
495
|
+
this.progressActive = false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------
|
|
500
|
+
// Hauptlauf
|
|
501
|
+
// ---------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
async run() {
|
|
504
|
+
const start = Date.now();
|
|
505
|
+
const {
|
|
506
|
+
target,
|
|
507
|
+
dryRun = false,
|
|
508
|
+
runUploadList = false,
|
|
509
|
+
runDownloadList = false,
|
|
510
|
+
skipSync = false,
|
|
511
|
+
cliLogLevel = null,
|
|
512
|
+
configPath,
|
|
513
|
+
} = this.options;
|
|
514
|
+
|
|
515
|
+
if (!target) {
|
|
516
|
+
console.error(pc.red("❌ No target specified."));
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const cfgPath = path.resolve(configPath || "sync.config.json");
|
|
521
|
+
if (!fs.existsSync(cfgPath)) {
|
|
522
|
+
console.error(pc.red(`❌ Configuration file missing: ${cfgPath}`));
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Config laden
|
|
527
|
+
let configRaw;
|
|
528
|
+
try {
|
|
529
|
+
configRaw = JSON.parse(await fsp.readFile(cfgPath, "utf8"));
|
|
530
|
+
} catch (err) {
|
|
531
|
+
console.error(
|
|
532
|
+
pc.red("❌ Error reading sync.config.json:"),
|
|
533
|
+
err?.message || err
|
|
534
|
+
);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (!configRaw.connections || typeof configRaw.connections !== "object") {
|
|
539
|
+
console.error(
|
|
540
|
+
pc.red("❌ sync.config.json must have a 'connections' field.")
|
|
541
|
+
);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const targetConfig = configRaw.connections[target];
|
|
546
|
+
if (!targetConfig) {
|
|
547
|
+
console.error(
|
|
548
|
+
pc.red(`❌ Connection '${target}' not found in sync.config.json.`)
|
|
549
|
+
);
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const syncCfg = targetConfig.sync ?? targetConfig;
|
|
554
|
+
const sidecarCfg = targetConfig.sidecar ?? {};
|
|
555
|
+
|
|
556
|
+
if (!syncCfg.localRoot || !syncCfg.remoteRoot) {
|
|
557
|
+
console.error(
|
|
558
|
+
pc.red(
|
|
559
|
+
`❌ Connection '${target}' is missing sync.localRoot or sync.remoteRoot.`
|
|
560
|
+
)
|
|
561
|
+
);
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
this.configRaw = configRaw;
|
|
566
|
+
this.targetConfig = targetConfig;
|
|
567
|
+
this.connection = {
|
|
568
|
+
host: targetConfig.host,
|
|
569
|
+
port: targetConfig.port ?? 22,
|
|
570
|
+
user: targetConfig.user,
|
|
571
|
+
password: targetConfig.password,
|
|
572
|
+
localRoot: path.resolve(syncCfg.localRoot),
|
|
573
|
+
remoteRoot: syncCfg.remoteRoot,
|
|
574
|
+
sidecarLocalRoot: path.resolve(sidecarCfg.localRoot ?? syncCfg.localRoot),
|
|
575
|
+
sidecarRemoteRoot: sidecarCfg.remoteRoot ?? syncCfg.remoteRoot,
|
|
576
|
+
workers: targetConfig.worker ?? 2,
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// LogLevel
|
|
580
|
+
let logLevel = (configRaw.logLevel ?? "normal").toLowerCase();
|
|
581
|
+
if (cliLogLevel) logLevel = cliLogLevel;
|
|
582
|
+
this.logLevel = logLevel;
|
|
583
|
+
this.isVerbose = logLevel === "verbose";
|
|
584
|
+
this.isLaconic = logLevel === "laconic";
|
|
585
|
+
|
|
586
|
+
// Progress-Konfig
|
|
587
|
+
const PROGRESS = configRaw.progress ?? {};
|
|
588
|
+
this.scanChunk = PROGRESS.scanChunk ?? (this.isVerbose ? 1 : 100);
|
|
589
|
+
this.analyzeChunk = PROGRESS.analyzeChunk ?? (this.isVerbose ? 1 : 10);
|
|
590
|
+
this.parallelScan = PROGRESS.parallelScan ?? true;
|
|
591
|
+
|
|
592
|
+
this.cleanupEmptyDirsEnabled = configRaw.cleanupEmptyDirs ?? true;
|
|
593
|
+
this.cleanupEmptyRoots = configRaw.cleanupEmptyRoots ?? false;
|
|
594
|
+
|
|
595
|
+
// Patterns
|
|
596
|
+
this.includePatterns = configRaw.include ?? [];
|
|
597
|
+
this.baseExcludePatterns = configRaw.exclude ?? [];
|
|
598
|
+
|
|
599
|
+
// Dateitypen
|
|
600
|
+
this.textExt =
|
|
601
|
+
configRaw.textExtensions ?? [
|
|
602
|
+
".html",
|
|
603
|
+
".htm",
|
|
604
|
+
".xml",
|
|
605
|
+
".txt",
|
|
606
|
+
".json",
|
|
607
|
+
".js",
|
|
608
|
+
".mjs",
|
|
609
|
+
".cjs",
|
|
610
|
+
".css",
|
|
611
|
+
".md",
|
|
612
|
+
".svg",
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
this.mediaExt =
|
|
616
|
+
configRaw.mediaExtensions ?? [
|
|
617
|
+
".jpg",
|
|
618
|
+
".jpeg",
|
|
619
|
+
".png",
|
|
620
|
+
".gif",
|
|
621
|
+
".webp",
|
|
622
|
+
".avif",
|
|
623
|
+
".mp4",
|
|
624
|
+
".mov",
|
|
625
|
+
".mp3",
|
|
626
|
+
".wav",
|
|
627
|
+
".ogg",
|
|
628
|
+
".flac",
|
|
629
|
+
".pdf",
|
|
630
|
+
];
|
|
631
|
+
|
|
632
|
+
const normalizeList = (list) => {
|
|
633
|
+
if (!Array.isArray(list)) return [];
|
|
634
|
+
return list.flatMap((item) =>
|
|
635
|
+
typeof item === "string"
|
|
636
|
+
? item
|
|
637
|
+
.split(",")
|
|
638
|
+
.map((s) => s.trim())
|
|
639
|
+
.filter(Boolean)
|
|
640
|
+
: []
|
|
641
|
+
);
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
this.uploadList = normalizeList(sidecarCfg.uploadList ?? []);
|
|
645
|
+
this.downloadList = normalizeList(sidecarCfg.downloadList ?? []);
|
|
646
|
+
this.excludePatterns = [
|
|
647
|
+
...this.baseExcludePatterns,
|
|
648
|
+
...this.uploadList,
|
|
649
|
+
...this.downloadList,
|
|
650
|
+
];
|
|
651
|
+
this.autoExcluded = new Set();
|
|
652
|
+
|
|
653
|
+
// Hash-Cache
|
|
654
|
+
const syncCacheName =
|
|
655
|
+
targetConfig.syncCache || `.sync-cache.${target}.json`;
|
|
656
|
+
const cachePath = path.resolve(syncCacheName);
|
|
657
|
+
this.hashCache = createHashCache({
|
|
658
|
+
cachePath,
|
|
659
|
+
namespace: target,
|
|
660
|
+
flushInterval: 50,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Logger
|
|
664
|
+
const DEFAULT_LOG_FILE = `.sync.${target}.log`;
|
|
665
|
+
const rawLogFilePattern = configRaw.logFile || DEFAULT_LOG_FILE;
|
|
666
|
+
const logFile = path.resolve(
|
|
667
|
+
rawLogFilePattern.replace("{target}", target)
|
|
668
|
+
);
|
|
669
|
+
this.logger = new SyncLogger(logFile);
|
|
670
|
+
await this.logger.init();
|
|
671
|
+
|
|
672
|
+
// Header
|
|
673
|
+
this.log("\n" + hr2());
|
|
674
|
+
this.log(
|
|
675
|
+
pc.bold(
|
|
676
|
+
`🔐 SFTP Push-Synchronisation: sftp-push-sync v${pkg.version}`
|
|
677
|
+
)
|
|
678
|
+
);
|
|
679
|
+
this.log(`${TAB_A}LogLevel: ${this.logLevel}`);
|
|
680
|
+
this.log(`${TAB_A}Connection: ${pc.cyan(target)}`);
|
|
681
|
+
this.log(`${TAB_A}Worker: ${this.connection.workers}`);
|
|
682
|
+
this.log(
|
|
683
|
+
`${TAB_A}Host: ${pc.green(this.connection.host)}:${pc.green(
|
|
684
|
+
this.connection.port
|
|
685
|
+
)}`
|
|
686
|
+
);
|
|
687
|
+
this.log(`${TAB_A}Local: ${pc.green(this.connection.localRoot)}`);
|
|
688
|
+
this.log(`${TAB_A}Remote: ${pc.green(this.connection.remoteRoot)}`);
|
|
689
|
+
if (runUploadList || runDownloadList || skipSync) {
|
|
690
|
+
this.log(
|
|
691
|
+
`${TAB_A}Sidecar Local: ${pc.green(this.connection.sidecarLocalRoot)}`
|
|
692
|
+
);
|
|
693
|
+
this.log(
|
|
694
|
+
`${TAB_A}Sidecar Remote: ${pc.green(
|
|
695
|
+
this.connection.sidecarRemoteRoot
|
|
696
|
+
)}`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
if (dryRun) this.log(pc.yellow(`${TAB_A}Mode: DRY-RUN (no changes)`));
|
|
700
|
+
if (skipSync) this.log(pc.yellow(`${TAB_A}Mode: SKIP-SYNC (bypass only)`));
|
|
701
|
+
if (runUploadList || runDownloadList) {
|
|
702
|
+
this.log(
|
|
703
|
+
pc.blue(
|
|
704
|
+
`${TAB_A}Extra: ${
|
|
705
|
+
runUploadList ? "sidecar-upload " : ""
|
|
706
|
+
}${runDownloadList ? "sidecar-download" : ""}`
|
|
707
|
+
)
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
if (this.cleanupEmptyDirsEnabled) {
|
|
711
|
+
this.log(`${TAB_A}Cleanup empty dirs: ${pc.green("enabled")}`);
|
|
712
|
+
}
|
|
713
|
+
if (logFile) {
|
|
714
|
+
this.log(`${TAB_A}LogFile: ${pc.cyan(logFile)}`);
|
|
715
|
+
}
|
|
716
|
+
this.log(hr1());
|
|
717
|
+
|
|
718
|
+
const sftp = new SftpClient();
|
|
719
|
+
let connected = false;
|
|
720
|
+
|
|
721
|
+
let toAdd = [];
|
|
722
|
+
let toUpdate = [];
|
|
723
|
+
let toDelete = [];
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
this.log("");
|
|
727
|
+
this.log(pc.cyan("🔌 Connecting to SFTP server …"));
|
|
728
|
+
await sftp.connect({
|
|
729
|
+
host: this.connection.host,
|
|
730
|
+
port: this.connection.port,
|
|
731
|
+
username: this.connection.user,
|
|
732
|
+
password: this.connection.password,
|
|
733
|
+
});
|
|
734
|
+
connected = true;
|
|
735
|
+
this.log(`${TAB_A}${pc.green("✔ Connected to SFTP.")}`);
|
|
736
|
+
|
|
737
|
+
if (!skipSync && !fs.existsSync(this.connection.localRoot)) {
|
|
738
|
+
this.elog(
|
|
739
|
+
pc.red("❌ Local root does not exist:"),
|
|
740
|
+
this.connection.localRoot
|
|
741
|
+
);
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Bypass-Only?
|
|
746
|
+
if (skipSync) {
|
|
747
|
+
await performSidecarBypass({
|
|
748
|
+
sftp,
|
|
749
|
+
connection: this.connection,
|
|
750
|
+
uploadList: this.uploadList,
|
|
751
|
+
downloadList: this.downloadList,
|
|
752
|
+
options: { dryRun, runUploadList, runDownloadList },
|
|
753
|
+
runTasks: (items, workers, handler, label) =>
|
|
754
|
+
this.runTasks(items, workers, handler, label),
|
|
755
|
+
log: (...m) => this.log(...m),
|
|
756
|
+
vlog: this.isVerbose ? (...m) => this.vlog(...m) : null,
|
|
757
|
+
elog: (...m) => this.elog(...m),
|
|
758
|
+
symbols: { ADD, CHA, tab_a: TAB_A },
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const duration = ((Date.now() - start) / 1000).toFixed(2);
|
|
762
|
+
this.log("");
|
|
763
|
+
this.log(pc.bold(pc.cyan("📊 Summary (bypass only):")));
|
|
764
|
+
this.log(`${TAB_A}Duration: ${pc.green(duration + " s")}`);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Phase 1 + 2 – Scan
|
|
769
|
+
this.log("");
|
|
770
|
+
this.log(
|
|
771
|
+
pc.bold(
|
|
772
|
+
pc.cyan(
|
|
773
|
+
`📥 Phase 1 + 2: Scan local & remote files (${
|
|
774
|
+
this.parallelScan ? "parallel" : "serial"
|
|
775
|
+
}) …`
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const scanProgress = new ScanProgressController({
|
|
781
|
+
writeLogLine: (line) => this._writeLogFile(line),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
let local;
|
|
785
|
+
let remote;
|
|
786
|
+
|
|
787
|
+
if (this.parallelScan) {
|
|
788
|
+
[local, remote] = await Promise.all([
|
|
789
|
+
walkLocal(this.connection.localRoot, {
|
|
790
|
+
filterFn: (rel) => this.isIncluded(rel),
|
|
791
|
+
classifyFn: (rel) => ({
|
|
792
|
+
isText: this.isTextFile(rel),
|
|
793
|
+
isMedia: this.isMediaFile(rel),
|
|
794
|
+
}),
|
|
795
|
+
progress: scanProgress,
|
|
796
|
+
scanChunk: this.scanChunk,
|
|
797
|
+
log: (msg) => this.log(msg),
|
|
798
|
+
}),
|
|
799
|
+
walkRemote(sftp, this.connection.remoteRoot, {
|
|
800
|
+
filterFn: (rel) => this.isIncluded(rel),
|
|
801
|
+
progress: scanProgress,
|
|
802
|
+
scanChunk: this.scanChunk,
|
|
803
|
+
log: (msg) => this.log(msg),
|
|
804
|
+
}),
|
|
805
|
+
]);
|
|
806
|
+
} else {
|
|
807
|
+
local = await walkLocal(this.connection.localRoot, {
|
|
808
|
+
filterFn: (rel) => this.isIncluded(rel),
|
|
809
|
+
classifyFn: (rel) => ({
|
|
810
|
+
isText: this.isTextFile(rel),
|
|
811
|
+
isMedia: this.isMediaFile(rel),
|
|
812
|
+
}),
|
|
813
|
+
progress: scanProgress,
|
|
814
|
+
scanChunk: this.scanChunk,
|
|
815
|
+
log: (msg) => this.log(msg),
|
|
816
|
+
});
|
|
817
|
+
remote = await walkRemote(sftp, this.connection.remoteRoot, {
|
|
818
|
+
filterFn: (rel) => this.isIncluded(rel),
|
|
819
|
+
progress: scanProgress,
|
|
820
|
+
scanChunk: this.scanChunk,
|
|
821
|
+
log: (msg) => this.log(msg),
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
scanProgress.stop();
|
|
826
|
+
|
|
827
|
+
this.log(`${TAB_A}→ ${local.size} local files`);
|
|
828
|
+
this.log(`${TAB_A}→ ${remote.size} remote files`);
|
|
829
|
+
|
|
830
|
+
if (this.autoExcluded.size > 0) {
|
|
831
|
+
this.log("");
|
|
832
|
+
this.log(pc.dim(" Auto-excluded (sidecar upload/download):"));
|
|
833
|
+
[...this.autoExcluded].sort().forEach((file) => {
|
|
834
|
+
this.log(pc.dim(`${TAB_A} - ${file}`));
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
this.log("");
|
|
839
|
+
|
|
840
|
+
// Phase 3 – Analyse Differences (delegiert an Helper)
|
|
841
|
+
this.log(pc.bold(pc.cyan("🔎 Phase 3: Compare & decide …")));
|
|
842
|
+
|
|
843
|
+
const { getLocalHash, getRemoteHash, save: saveCache } = this.hashCache;
|
|
844
|
+
|
|
845
|
+
const diffResult = await analyseDifferences({
|
|
846
|
+
local,
|
|
847
|
+
remote,
|
|
848
|
+
remoteRoot: this.connection.remoteRoot,
|
|
849
|
+
sftp,
|
|
850
|
+
getLocalHash,
|
|
851
|
+
getRemoteHash,
|
|
852
|
+
analyzeChunk: this.analyzeChunk,
|
|
853
|
+
updateProgress: (prefix, current, total, rel) =>
|
|
854
|
+
this.updateProgress2(prefix, current, total, rel, "Files"),
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
toAdd = diffResult.toAdd;
|
|
858
|
+
toUpdate = diffResult.toUpdate;
|
|
859
|
+
|
|
860
|
+
if (toAdd.length === 0 && toUpdate.length === 0) {
|
|
861
|
+
this.log("");
|
|
862
|
+
this.log(`${TAB_A}No differences found. Everything is up to date.`);
|
|
863
|
+
} else if (!this.isLaconic) {
|
|
864
|
+
this.log("");
|
|
865
|
+
this.log(pc.bold(pc.cyan("Changes (analysis):")));
|
|
866
|
+
[...toAdd].forEach((t) =>
|
|
867
|
+
this.log(`${TAB_A}${ADD} ${pc.green("New:")} ${t.rel}`)
|
|
868
|
+
);
|
|
869
|
+
[...toUpdate].forEach((t) =>
|
|
870
|
+
this.log(`${TAB_A}${CHA} ${pc.yellow("Changed:")} ${t.rel}`)
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Phase 4 – Remote deletes
|
|
875
|
+
this.log("");
|
|
876
|
+
this.log(pc.bold(pc.cyan("🧹 Phase 4: Removing orphaned remote files …")));
|
|
877
|
+
|
|
878
|
+
toDelete = computeRemoteDeletes({ local, remote });
|
|
879
|
+
|
|
880
|
+
if (toDelete.length === 0) {
|
|
881
|
+
this.log(`${TAB_A}No orphaned remote files found.`);
|
|
882
|
+
} else if (!this.isLaconic) {
|
|
883
|
+
toDelete.forEach((t) =>
|
|
884
|
+
this.log(`${TAB_A}${DEL} ${pc.red("Remove:")} ${t.rel}`)
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Verzeichnisse vorbereiten
|
|
889
|
+
if (!dryRun && (toAdd.length || toUpdate.length)) {
|
|
890
|
+
this.log("");
|
|
891
|
+
this.log(pc.bold(pc.cyan("📁 Preparing remote directories …")));
|
|
892
|
+
await this.ensureAllRemoteDirsExist(
|
|
893
|
+
sftp,
|
|
894
|
+
this.connection.remoteRoot,
|
|
895
|
+
toAdd,
|
|
896
|
+
toUpdate
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Phase 5 – Apply changes
|
|
901
|
+
if (!dryRun) {
|
|
902
|
+
this.log("");
|
|
903
|
+
this.log(pc.bold(pc.cyan("🚚 Phase 5: Apply changes …")));
|
|
904
|
+
|
|
905
|
+
// Upload new files
|
|
906
|
+
await this.runTasks(
|
|
907
|
+
toAdd,
|
|
908
|
+
this.connection.workers,
|
|
909
|
+
async ({ local: l, remotePath }) => {
|
|
910
|
+
const remoteDir = path.posix.dirname(remotePath);
|
|
911
|
+
try {
|
|
912
|
+
await sftp.mkdir(remoteDir, true);
|
|
913
|
+
} catch {
|
|
914
|
+
// Directory may already exist
|
|
915
|
+
}
|
|
916
|
+
await sftp.put(l.localPath, remotePath);
|
|
917
|
+
},
|
|
918
|
+
"Uploads (new)"
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
// Updates
|
|
922
|
+
await this.runTasks(
|
|
923
|
+
toUpdate,
|
|
924
|
+
this.connection.workers,
|
|
925
|
+
async ({ local: l, remotePath }) => {
|
|
926
|
+
const remoteDir = path.posix.dirname(remotePath);
|
|
927
|
+
try {
|
|
928
|
+
await sftp.mkdir(remoteDir, true);
|
|
929
|
+
} catch {
|
|
930
|
+
// Directory may already exist
|
|
931
|
+
}
|
|
932
|
+
await sftp.put(l.localPath, remotePath);
|
|
933
|
+
},
|
|
934
|
+
"Uploads (update)"
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
// Deletes
|
|
938
|
+
await this.runTasks(
|
|
939
|
+
toDelete,
|
|
940
|
+
this.connection.workers,
|
|
941
|
+
async ({ remotePath, rel }) => {
|
|
942
|
+
try {
|
|
943
|
+
await sftp.delete(remotePath);
|
|
944
|
+
} catch (e) {
|
|
945
|
+
this.elog(
|
|
946
|
+
pc.red(" ⚠️ Error during deletion:"),
|
|
947
|
+
rel || remotePath,
|
|
948
|
+
e?.message || e
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
"Deletes"
|
|
953
|
+
);
|
|
954
|
+
} else {
|
|
955
|
+
this.log("");
|
|
956
|
+
this.log(
|
|
957
|
+
pc.yellow(
|
|
958
|
+
"💡 DRY-RUN: Connection tested, no files transferred or deleted."
|
|
959
|
+
)
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Optional: leere Verzeichnisse aufräumen
|
|
964
|
+
if (!dryRun && this.cleanupEmptyDirsEnabled) {
|
|
965
|
+
this.log("");
|
|
966
|
+
this.log(
|
|
967
|
+
pc.bold(pc.cyan("🧹 Cleaning up empty remote directories …"))
|
|
968
|
+
);
|
|
969
|
+
await this.cleanupEmptyDirs(sftp, this.connection.remoteRoot, dryRun);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const duration = ((Date.now() - start) / 1000).toFixed(2);
|
|
973
|
+
|
|
974
|
+
// Cache am Ende sicher schreiben
|
|
975
|
+
await saveCache(true);
|
|
976
|
+
|
|
977
|
+
// Summary
|
|
978
|
+
this.log(hr1());
|
|
979
|
+
this.log("");
|
|
980
|
+
this.log(pc.bold(pc.cyan("📊 Summary:")));
|
|
981
|
+
this.log(`${TAB_A}Duration: ${pc.green(duration + " s")}`);
|
|
982
|
+
this.log(`${TAB_A}${ADD} Added : ${toAdd.length}`);
|
|
983
|
+
this.log(`${TAB_A}${CHA} Changed: ${toUpdate.length}`);
|
|
984
|
+
this.log(`${TAB_A}${DEL} Deleted: ${toDelete.length}`);
|
|
985
|
+
if (this.autoExcluded.size > 0) {
|
|
986
|
+
this.log(
|
|
987
|
+
`${TAB_A}${EXC} Excluded via sidecar upload/download: ${
|
|
988
|
+
this.autoExcluded.size
|
|
989
|
+
}`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Directory-Statistik
|
|
994
|
+
const dirsChecked =
|
|
995
|
+
this.dirStats.ensuredDirs + this.dirStats.cleanupVisited;
|
|
996
|
+
this.log("");
|
|
997
|
+
this.log(pc.bold("Folders:"));
|
|
998
|
+
this.log(`${TAB_A}Checked : ${dirsChecked}`);
|
|
999
|
+
this.log(`${TAB_A}${ADD} Created: ${this.dirStats.createdDirs}`);
|
|
1000
|
+
this.log(`${TAB_A}${DEL} Deleted: ${this.dirStats.cleanupDeleted}`);
|
|
1001
|
+
|
|
1002
|
+
if (toAdd.length || toUpdate.length || toDelete.length) {
|
|
1003
|
+
this.log("");
|
|
1004
|
+
this.log("📄 Changes:");
|
|
1005
|
+
[...toAdd.map((t) => t.rel)]
|
|
1006
|
+
.sort()
|
|
1007
|
+
.forEach((f) => console.log(`${TAB_A}${ADD} ${f}`));
|
|
1008
|
+
[...toUpdate.map((t) => t.rel)]
|
|
1009
|
+
.sort()
|
|
1010
|
+
.forEach((f) => console.log(`${TAB_A}${CHA} ${f}`));
|
|
1011
|
+
[...toDelete.map((t) => t.rel)]
|
|
1012
|
+
.sort()
|
|
1013
|
+
.forEach((f) => console.log(`${TAB_A}${DEL} ${f}`));
|
|
1014
|
+
} else {
|
|
1015
|
+
this.log("");
|
|
1016
|
+
this.log("No changes.");
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
this.log("");
|
|
1020
|
+
this.log(pc.bold(pc.green("✅ Sync complete.")));
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
const hint = describeSftpError(err);
|
|
1023
|
+
this.elog(pc.red("❌ Synchronisation error:"), err?.message || err);
|
|
1024
|
+
if (hint) {
|
|
1025
|
+
this.wlog(pc.yellow(`${TAB_A}Possible cause:`), hint);
|
|
1026
|
+
}
|
|
1027
|
+
if (this.isVerbose) {
|
|
1028
|
+
console.error(err);
|
|
1029
|
+
}
|
|
1030
|
+
process.exitCode = 1;
|
|
1031
|
+
try {
|
|
1032
|
+
// falls hashCache existiert, Cache noch flushen
|
|
1033
|
+
if (this.hashCache?.save) {
|
|
1034
|
+
await this.hashCache.save(true);
|
|
1035
|
+
}
|
|
1036
|
+
} catch {
|
|
1037
|
+
// ignore
|
|
1038
|
+
}
|
|
1039
|
+
} finally {
|
|
1040
|
+
try {
|
|
1041
|
+
if (connected) {
|
|
1042
|
+
await sftp.end();
|
|
1043
|
+
this.log(pc.green(`${TAB_A}✔ Connection closed.`));
|
|
1044
|
+
}
|
|
1045
|
+
} catch (e) {
|
|
1046
|
+
this.wlog(
|
|
1047
|
+
pc.yellow("⚠️ Could not close SFTP connection cleanly:"),
|
|
1048
|
+
e?.message || e
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
this.log(hr2());
|
|
1053
|
+
this.log("");
|
|
1054
|
+
|
|
1055
|
+
if (this.logger) {
|
|
1056
|
+
this.logger.close();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|