sftp-push-sync 2.1.2 → 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 +15 -2
- package/bin/sftp-push-sync.mjs +126 -1363
- package/directory-structure.txt +14 -0
- package/images/example-output-002.jpg +0 -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
package/bin/sftp-push-sync.mjs
CHANGED
|
@@ -30,1407 +30,170 @@
|
|
|
30
30
|
* Delete Folders if
|
|
31
31
|
* - If, for example, a directory is empty because all files have been deleted from it.
|
|
32
32
|
* - Or if a directory no longer exists locally.
|
|
33
|
-
*
|
|
33
|
+
*
|
|
34
34
|
* The file sftp-push-sync.mjs is pure JavaScript (ESM), not TypeScript.
|
|
35
35
|
* Node.js can execute it directly as long as "type": "module" is specified in package.json
|
|
36
36
|
* or the file has the extension .mjs.
|
|
37
37
|
*/
|
|
38
38
|
// bin/sftp-push-sync.mjs
|
|
39
|
-
import fs from "fs";
|
|
40
|
-
import fsp from "fs/promises";
|
|
41
|
-
import path from "path";
|
|
42
|
-
import SftpClient from "ssh2-sftp-client";
|
|
43
|
-
import { minimatch } from "minimatch";
|
|
44
|
-
import { diffWords } from "diff";
|
|
45
|
-
import { createHash } from "crypto";
|
|
46
|
-
import { Writable } from "stream";
|
|
47
39
|
import pc from "picocolors";
|
|
40
|
+
import { SftpPushSyncApp } from "../src/core/SftpPushSyncApp.mjs";
|
|
48
41
|
|
|
49
|
-
// get Versionsnummer
|
|
50
|
-
import { createRequire } from "module";
|
|
51
|
-
const require = createRequire(import.meta.url);
|
|
52
|
-
const pkg = require("../package.json");
|
|
53
|
-
|
|
54
|
-
// Colors for the State (works on dark + light background)
|
|
55
|
-
const ADD = pc.green("+"); // Added
|
|
56
|
-
const CHA = pc.yellow("~"); // Changed
|
|
57
|
-
const DEL = pc.red("-"); // Deleted
|
|
58
|
-
const EXC = pc.redBright("-"); // Excluded
|
|
59
|
-
|
|
60
|
-
const hr1 = () => "─".repeat(65); // horizontal line -
|
|
61
|
-
const hr2 = () => "=".repeat(65); // horizontal line =
|
|
62
|
-
const tab_a = () => " ".repeat(3); // indentation for formatting the terminal output.
|
|
63
|
-
const tab_b = () => " ".repeat(6);
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// CLI arguments
|
|
67
42
|
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
const args = process.argv.slice(2);
|
|
70
|
-
const TARGET = args[0];
|
|
71
|
-
const DRY_RUN = args.includes("--dry-run");
|
|
72
|
-
const RUN_UPLOAD_LIST = args.includes("--sidecar-upload");
|
|
73
|
-
const RUN_DOWNLOAD_LIST = args.includes("--sidecar-download");
|
|
74
|
-
const SKIP_SYNC = args.includes("--skip-sync");
|
|
75
|
-
|
|
76
|
-
// logLevel override via CLI (optional)
|
|
77
|
-
let cliLogLevel = null;
|
|
78
|
-
if (args.includes("--verbose")) cliLogLevel = "verbose";
|
|
79
|
-
if (args.includes("--laconic")) cliLogLevel = "laconic";
|
|
80
|
-
|
|
81
|
-
if (!TARGET) {
|
|
82
|
-
console.error(pc.red("❌ Please specify a connection profile:"));
|
|
83
|
-
console.error(pc.yellow(`${tab_a()}sftp-push-sync staging --dry-run`));
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Wenn jemand --skip-sync ohne Listen benutzt → sinnlos, also abbrechen
|
|
88
|
-
if (SKIP_SYNC && !RUN_UPLOAD_LIST && !RUN_DOWNLOAD_LIST) {
|
|
89
|
-
console.error(
|
|
90
|
-
pc.red(
|
|
91
|
-
"❌ --skip-sync requires at least --sidecar-upload or --sidecar-download."
|
|
92
|
-
)
|
|
93
|
-
);
|
|
94
|
-
process.exit(1);
|
|
95
|
-
}
|
|
96
|
-
|
|
43
|
+
// CLI-Arguments
|
|
97
44
|
// ---------------------------------------------------------------------------
|
|
98
|
-
//
|
|
45
|
+
//
|
|
46
|
+
// Call examples:
|
|
47
|
+
//
|
|
48
|
+
// sftp-push-sync staging --dry-run
|
|
49
|
+
// sftp-push-sync live --sidecar-upload --skip-sync
|
|
50
|
+
// sftp-push-sync live --config ./config/sync.live.json
|
|
51
|
+
//
|
|
52
|
+
// Die Struktur:
|
|
53
|
+
// [0] = target
|
|
54
|
+
// [1..] = Flags
|
|
99
55
|
// ---------------------------------------------------------------------------
|
|
100
56
|
|
|
101
|
-
const
|
|
57
|
+
const rawArgs = process.argv.slice(2);
|
|
102
58
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
59
|
+
// Help?
|
|
60
|
+
if (
|
|
61
|
+
rawArgs.length === 0 ||
|
|
62
|
+
rawArgs.includes("--help") ||
|
|
63
|
+
rawArgs.includes("-h")
|
|
64
|
+
) {
|
|
65
|
+
printUsage();
|
|
66
|
+
process.exit(rawArgs.length === 0 ? 1 : 0);
|
|
106
67
|
}
|
|
107
68
|
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
CONFIG_RAW = JSON.parse(await fsp.readFile(CONFIG_PATH, "utf8"));
|
|
111
|
-
} catch (err) {
|
|
112
|
-
console.error(pc.red("❌ Error reading sync.config.json:"), err.message);
|
|
113
|
-
process.exit(1);
|
|
114
|
-
}
|
|
69
|
+
const TARGET = rawArgs[0];
|
|
115
70
|
|
|
116
|
-
|
|
117
|
-
|
|
71
|
+
// If someone only passes flags but no target name
|
|
72
|
+
if (!TARGET || TARGET.startsWith("-")) {
|
|
73
|
+
console.error(pc.red("❌ Please specify a connection profile.\n"));
|
|
74
|
+
printUsage();
|
|
118
75
|
process.exit(1);
|
|
119
76
|
}
|
|
120
77
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function rawConsoleWarn(...msg) {
|
|
177
|
-
clearProgressLine();
|
|
178
|
-
console.warn(...msg);
|
|
179
|
-
const line = msg
|
|
180
|
-
.map((m) => (typeof m === "string" ? m : String(m)))
|
|
181
|
-
.join(" ");
|
|
182
|
-
writeLogLine("[WARN] " + line);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// High-level Helfer
|
|
186
|
-
function log(...msg) {
|
|
187
|
-
rawConsoleLog(...msg);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function vlog(...msg) {
|
|
191
|
-
if (!IS_VERBOSE) return;
|
|
192
|
-
rawConsoleLog(...msg);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function elog(...msg) {
|
|
196
|
-
rawConsoleError(...msg);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function wlog(...msg) {
|
|
200
|
-
rawConsoleWarn(...msg);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ---------------------------------------------------------------------------
|
|
204
|
-
// Connection
|
|
205
|
-
// ---------------------------------------------------------------------------
|
|
206
|
-
|
|
207
|
-
const TARGET_CONFIG = CONFIG_RAW.connections[TARGET];
|
|
208
|
-
if (!TARGET_CONFIG) {
|
|
209
|
-
console.error(
|
|
210
|
-
pc.red(`❌ Connection '${TARGET}' not found in sync.config.json.`)
|
|
211
|
-
);
|
|
212
|
-
process.exit(1);
|
|
213
|
-
}
|
|
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) {
|
|
78
|
+
// Evaluate flags from position 1 onwards
|
|
79
|
+
let DRY_RUN = false;
|
|
80
|
+
let RUN_UPLOAD_LIST = false;
|
|
81
|
+
let RUN_DOWNLOAD_LIST = false;
|
|
82
|
+
let SKIP_SYNC = false;
|
|
83
|
+
let cliLogLevel = null;
|
|
84
|
+
let configPath = undefined;
|
|
85
|
+
|
|
86
|
+
const rest = rawArgs.slice(1);
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
89
|
+
const a = rest[i];
|
|
90
|
+
|
|
91
|
+
switch (a) {
|
|
92
|
+
case "--dry-run":
|
|
93
|
+
DRY_RUN = true;
|
|
94
|
+
break;
|
|
95
|
+
case "--sidecar-upload":
|
|
96
|
+
RUN_UPLOAD_LIST = true;
|
|
97
|
+
break;
|
|
98
|
+
case "--sidecar-download":
|
|
99
|
+
RUN_DOWNLOAD_LIST = true;
|
|
100
|
+
break;
|
|
101
|
+
case "--skip-sync":
|
|
102
|
+
SKIP_SYNC = true;
|
|
103
|
+
break;
|
|
104
|
+
case "--verbose":
|
|
105
|
+
cliLogLevel = "verbose";
|
|
106
|
+
break;
|
|
107
|
+
case "--laconic":
|
|
108
|
+
cliLogLevel = "laconic";
|
|
109
|
+
break;
|
|
110
|
+
case "--config":
|
|
111
|
+
case "-c": {
|
|
112
|
+
const next = rest[i + 1];
|
|
113
|
+
if (!next || next.startsWith("-")) {
|
|
114
|
+
console.error(
|
|
115
|
+
pc.red("❌ --config expects a path argument (e.g. --config sync.config.json)")
|
|
116
|
+
);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
configPath = next;
|
|
120
|
+
i += 1; // Pfad überspringen
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
console.error(pc.yellow(`⚠️ Unknown argument ignored: ${a}`));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --skip-sync without lists → error
|
|
130
|
+
if (SKIP_SYNC && !RUN_UPLOAD_LIST && !RUN_DOWNLOAD_LIST) {
|
|
220
131
|
console.error(
|
|
221
132
|
pc.red(
|
|
222
|
-
|
|
133
|
+
"❌ --skip-sync requires at least --sidecar-upload or --sidecar-download."
|
|
223
134
|
)
|
|
224
135
|
);
|
|
225
136
|
process.exit(1);
|
|
226
137
|
}
|
|
227
138
|
|
|
228
|
-
const CONNECTION = {
|
|
229
|
-
host: TARGET_CONFIG.host,
|
|
230
|
-
port: TARGET_CONFIG.port ?? 22,
|
|
231
|
-
user: TARGET_CONFIG.user,
|
|
232
|
-
password: TARGET_CONFIG.password,
|
|
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,
|
|
239
|
-
workers: TARGET_CONFIG.worker ?? 2,
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
// ---------------------------------------------------------------------------
|
|
243
|
-
// LogLevel + Progress aus Config
|
|
244
|
-
// ---------------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
// logLevel: "verbose", "normal", "laconic"
|
|
247
|
-
let LOG_LEVEL = (CONFIG_RAW.logLevel ?? "normal").toLowerCase();
|
|
248
|
-
|
|
249
|
-
// Override config with CLI flags
|
|
250
|
-
if (cliLogLevel) {
|
|
251
|
-
LOG_LEVEL = cliLogLevel;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const IS_VERBOSE = LOG_LEVEL === "verbose";
|
|
255
|
-
const IS_LACONIC = LOG_LEVEL === "laconic";
|
|
256
|
-
|
|
257
|
-
const PROGRESS = CONFIG_RAW.progress ?? {};
|
|
258
|
-
const SCAN_CHUNK = PROGRESS.scanChunk ?? (IS_VERBOSE ? 1 : 100);
|
|
259
|
-
const ANALYZE_CHUNK = PROGRESS.analyzeChunk ?? (IS_VERBOSE ? 1 : 10);
|
|
260
|
-
// For >100k files, rather 10–50, for debugging/troubleshooting 1.
|
|
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
|
-
|
|
266
139
|
// ---------------------------------------------------------------------------
|
|
267
|
-
//
|
|
140
|
+
// Usage
|
|
268
141
|
// ---------------------------------------------------------------------------
|
|
269
142
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
".json"
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
".svg",
|
|
286
|
-
];
|
|
287
|
-
|
|
288
|
-
// mediaExtensions – aktuell nur Meta, aber schon konfigurierbar
|
|
289
|
-
const MEDIA_EXT = CONFIG_RAW.mediaExtensions ?? [
|
|
290
|
-
".jpg",
|
|
291
|
-
".jpeg",
|
|
292
|
-
".png",
|
|
293
|
-
".gif",
|
|
294
|
-
".webp",
|
|
295
|
-
".avif",
|
|
296
|
-
".mp4",
|
|
297
|
-
".mov",
|
|
298
|
-
".mp3",
|
|
299
|
-
".wav",
|
|
300
|
-
".ogg",
|
|
301
|
-
".flac",
|
|
302
|
-
".pdf",
|
|
303
|
-
];
|
|
304
|
-
|
|
305
|
-
// Special: Lists for targeted uploads/downloads (per-connection sidecar)
|
|
306
|
-
function normalizeList(list) {
|
|
307
|
-
if (!Array.isArray(list)) return [];
|
|
308
|
-
return list.flatMap((item) =>
|
|
309
|
-
typeof item === "string"
|
|
310
|
-
? item
|
|
311
|
-
.split(",")
|
|
312
|
-
.map((s) => s.trim())
|
|
313
|
-
.filter(Boolean)
|
|
314
|
-
: []
|
|
143
|
+
function printUsage() {
|
|
144
|
+
/* eslint-disable no-console */
|
|
145
|
+
console.log("");
|
|
146
|
+
console.log(pc.bold("Usage:"));
|
|
147
|
+
console.log(" sftp-push-sync <target> [options]");
|
|
148
|
+
console.log("");
|
|
149
|
+
console.log(pc.bold("Examples:"));
|
|
150
|
+
console.log(" sftp-push-sync staging --dry-run");
|
|
151
|
+
console.log(" sftp-push-sync live --sidecar-upload --skip-sync");
|
|
152
|
+
console.log(" sftp-push-sync live --config ./sync.config.live.json");
|
|
153
|
+
console.log("");
|
|
154
|
+
console.log(pc.bold("Options:"));
|
|
155
|
+
console.log(" --dry-run Do not change anything, just simulate");
|
|
156
|
+
console.log(
|
|
157
|
+
" --sidecar-upload Run sidecar upload list (from sync.config.json)"
|
|
315
158
|
);
|
|
316
|
-
|
|
317
|
-
|
|
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 ?? []);
|
|
321
|
-
|
|
322
|
-
// Effektive Exclude-Liste: explizites exclude + Upload/Download-Listen
|
|
323
|
-
// → diese Dateien werden im „normalen“ Sync nicht angerührt,
|
|
324
|
-
// sondern nur über die Sidecar-Mechanik behandelt.
|
|
325
|
-
const EXCLUDE = [...BASE_EXCLUDE, ...UPLOAD_LIST, ...DOWNLOAD_LIST];
|
|
326
|
-
|
|
327
|
-
// List of ALL files that were ausgeschlossen durch uploadList/downloadList
|
|
328
|
-
const AUTO_EXCLUDED = new Set();
|
|
329
|
-
|
|
330
|
-
// Cache file name per connection
|
|
331
|
-
const syncCacheName = TARGET_CONFIG.syncCache || `.sync-cache.${TARGET}.json`;
|
|
332
|
-
const CACHE_PATH = path.resolve(syncCacheName);
|
|
333
|
-
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
// Load/initialise hash cache
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
|
|
338
|
-
let CACHE = {
|
|
339
|
-
version: 1,
|
|
340
|
-
local: {}, // key: "<TARGET>:<relPath>" -> { size, mtimeMs, hash }
|
|
341
|
-
remote: {}, // key: "<TARGET>:<relPath>" -> { size, modifyTime, hash }
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
if (fs.existsSync(CACHE_PATH)) {
|
|
346
|
-
const raw = JSON.parse(await fsp.readFile(CACHE_PATH, "utf8"));
|
|
347
|
-
CACHE.version = raw.version ?? 1;
|
|
348
|
-
CACHE.local = raw.local ?? {};
|
|
349
|
-
CACHE.remote = raw.remote ?? {};
|
|
350
|
-
}
|
|
351
|
-
} catch (err) {
|
|
352
|
-
console.warn(
|
|
353
|
-
pc.yellow("⚠️ Could not load cache, starting without:"),
|
|
354
|
-
err.message
|
|
159
|
+
console.log(
|
|
160
|
+
" --sidecar-download Run sidecar download list (from sync.config.json)"
|
|
355
161
|
);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
function cacheKey(relPath) {
|
|
359
|
-
return `${TARGET}:${relPath}`;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
let cacheDirty = false;
|
|
363
|
-
let cacheDirtyCount = 0;
|
|
364
|
-
const CACHE_FLUSH_INTERVAL = 50; // Write cache to disk after 50 new hashes
|
|
365
|
-
|
|
366
|
-
async function saveCache(force = false) {
|
|
367
|
-
if (!cacheDirty && !force) return;
|
|
368
|
-
const data = JSON.stringify(CACHE, null, 2);
|
|
369
|
-
await fsp.writeFile(CACHE_PATH, data, "utf8");
|
|
370
|
-
cacheDirty = false;
|
|
371
|
-
cacheDirtyCount = 0;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function markCacheDirty() {
|
|
375
|
-
cacheDirty = true;
|
|
376
|
-
cacheDirtyCount += 1;
|
|
377
|
-
if (cacheDirtyCount >= CACHE_FLUSH_INTERVAL) {
|
|
378
|
-
await saveCache();
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
// Helpers
|
|
384
|
-
// ---------------------------------------------------------------------------
|
|
385
|
-
|
|
386
|
-
let progressActive = false;
|
|
387
|
-
|
|
388
|
-
function clearProgressLine() {
|
|
389
|
-
if (!process.stdout.isTTY || !progressActive) return;
|
|
390
|
-
|
|
391
|
-
// Zwei Progress-Zeilen ohne zusätzliche Newlines leeren:
|
|
392
|
-
// Cursor steht nach updateProgress2() auf der ersten Zeile.
|
|
393
|
-
process.stdout.write("\r"); // an Zeilenanfang
|
|
394
|
-
process.stdout.write("\x1b[2K"); // erste Zeile löschen
|
|
395
|
-
process.stdout.write("\x1b[1B"); // eine Zeile nach unten
|
|
396
|
-
process.stdout.write("\x1b[2K"); // zweite Zeile löschen
|
|
397
|
-
process.stdout.write("\x1b[1A"); // wieder nach oben
|
|
398
|
-
|
|
399
|
-
progressActive = false;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function toPosix(p) {
|
|
403
|
-
return p.split(path.sep).join("/");
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function matchesAny(patterns, relPath) {
|
|
407
|
-
if (!patterns || patterns.length === 0) return false;
|
|
408
|
-
return patterns.some((pattern) => minimatch(relPath, pattern, { dot: true }));
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function isIncluded(relPath) {
|
|
412
|
-
// Include-Regeln
|
|
413
|
-
if (INCLUDE.length > 0 && !matchesAny(INCLUDE, relPath)) return false;
|
|
414
|
-
// Exclude-Regeln
|
|
415
|
-
if (EXCLUDE.length > 0 && matchesAny(EXCLUDE, relPath)) {
|
|
416
|
-
// Falls durch Sidecar-Listen → merken
|
|
417
|
-
if (UPLOAD_LIST.includes(relPath) || DOWNLOAD_LIST.includes(relPath)) {
|
|
418
|
-
AUTO_EXCLUDED.add(relPath);
|
|
419
|
-
}
|
|
420
|
-
return false;
|
|
421
|
-
}
|
|
422
|
-
return true;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function isTextFile(relPath) {
|
|
426
|
-
const ext = path.extname(relPath).toLowerCase();
|
|
427
|
-
return TEXT_EXT.includes(ext);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function isMediaFile(relPath) {
|
|
431
|
-
const ext = path.extname(relPath).toLowerCase();
|
|
432
|
-
return MEDIA_EXT.includes(ext);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function shortenPathForProgress(rel) {
|
|
436
|
-
if (!rel) return "";
|
|
437
|
-
const parts = rel.split("/");
|
|
438
|
-
if (parts.length === 1) {
|
|
439
|
-
return rel; // nur Dateiname
|
|
440
|
-
}
|
|
441
|
-
if (parts.length === 2) {
|
|
442
|
-
return rel; // schon kurz genug
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const last = parts[parts.length - 1];
|
|
446
|
-
const prev = parts[parts.length - 2];
|
|
447
|
-
|
|
448
|
-
// z.B. …/images/foo.jpg
|
|
449
|
-
return `…/${prev}/${last}`;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Two-line progress bar (for terminal) + 1-line log entry
|
|
453
|
-
function updateProgress2(prefix, current, total, rel = "") {
|
|
454
|
-
const short = rel ? shortenPathForProgress(rel) : "";
|
|
455
|
-
|
|
456
|
-
// Log file: always as a single line with **full** rel path
|
|
457
|
-
const base =
|
|
458
|
-
total && total > 0
|
|
459
|
-
? `${prefix}${current}/${total} Files`
|
|
460
|
-
: `${prefix}${current} Files`;
|
|
461
|
-
writeLogLine(`[progress] ${base}${rel ? " – " + rel : ""}`);
|
|
462
|
-
|
|
463
|
-
if (!process.stdout.isTTY) {
|
|
464
|
-
// Fallback-Terminal
|
|
465
|
-
if (total && total > 0) {
|
|
466
|
-
const percent = ((current / total) * 100).toFixed(1);
|
|
467
|
-
console.log(
|
|
468
|
-
`${tab_a()}${prefix}${current}/${total} Files (${percent}%) – ${short}`
|
|
469
|
-
);
|
|
470
|
-
} else {
|
|
471
|
-
console.log(`${tab_a()}${prefix}${current} Files – ${short}`);
|
|
472
|
-
}
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const width = process.stdout.columns || 80;
|
|
477
|
-
|
|
478
|
-
let line1;
|
|
479
|
-
if (total && total > 0) {
|
|
480
|
-
const percent = ((current / total) * 100).toFixed(1);
|
|
481
|
-
line1 = `${tab_a()}${prefix}${current}/${total} Files (${percent}%)`;
|
|
482
|
-
} else {
|
|
483
|
-
// „unknown total“ / Scanner-Modus
|
|
484
|
-
line1 = `${tab_a()}${prefix}${current} Files`;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
let line2 = short;
|
|
488
|
-
|
|
489
|
-
if (line1.length > width) line1 = line1.slice(0, width - 1);
|
|
490
|
-
if (line2.length > width) line2 = line2.slice(0, width - 1);
|
|
491
|
-
|
|
492
|
-
// zwei Zeilen überschreiben
|
|
493
|
-
process.stdout.write("\r" + line1.padEnd(width) + "\n");
|
|
494
|
-
process.stdout.write(line2.padEnd(width));
|
|
495
|
-
|
|
496
|
-
// Cursor wieder nach oben (auf die Fortschrittszeile)
|
|
497
|
-
process.stdout.write("\x1b[1A");
|
|
498
|
-
|
|
499
|
-
progressActive = true;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// Simple worker pool for parallel tasks
|
|
503
|
-
async function runTasks(items, workerCount, handler, label = "Tasks") {
|
|
504
|
-
if (!items || items.length === 0) return;
|
|
505
|
-
|
|
506
|
-
const total = items.length;
|
|
507
|
-
let done = 0;
|
|
508
|
-
let index = 0;
|
|
509
|
-
|
|
510
|
-
async function worker() {
|
|
511
|
-
// eslint-disable-next-line no-constant-condition
|
|
512
|
-
while (true) {
|
|
513
|
-
const i = index;
|
|
514
|
-
if (i >= total) break;
|
|
515
|
-
index += 1;
|
|
516
|
-
const item = items[i];
|
|
517
|
-
try {
|
|
518
|
-
await handler(item);
|
|
519
|
-
} catch (err) {
|
|
520
|
-
elog(pc.red(`${tab_a()}⚠️ Error in ${label}:`), err.message || err);
|
|
521
|
-
}
|
|
522
|
-
done += 1;
|
|
523
|
-
if (done === 1 || done % 10 === 0 || done === total) {
|
|
524
|
-
updateProgress2(`${label}: `, done, total, item.rel ?? "");
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const workers = [];
|
|
530
|
-
const actualWorkers = Math.max(1, Math.min(workerCount, total));
|
|
531
|
-
for (let i = 0; i < actualWorkers; i += 1) {
|
|
532
|
-
workers.push(worker());
|
|
533
|
-
}
|
|
534
|
-
await Promise.all(workers);
|
|
535
|
-
}
|
|
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
|
|
162
|
+
console.log(
|
|
163
|
+
" --skip-sync Skip normal sync, only run sidecar upload/download"
|
|
561
164
|
);
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
655
|
-
// ---------------------------------------------------------------------------
|
|
656
|
-
// Local file walker (recursive, all subdirectories)
|
|
657
|
-
// ---------------------------------------------------------------------------
|
|
658
|
-
|
|
659
|
-
async function walkLocal(root) {
|
|
660
|
-
const result = new Map();
|
|
661
|
-
let scanned = 0;
|
|
662
|
-
|
|
663
|
-
async function recurse(current) {
|
|
664
|
-
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
665
|
-
for (const entry of entries) {
|
|
666
|
-
const full = path.join(current, entry.name);
|
|
667
|
-
if (entry.isDirectory()) {
|
|
668
|
-
await recurse(full);
|
|
669
|
-
} else if (entry.isFile()) {
|
|
670
|
-
const rel = toPosix(path.relative(root, full));
|
|
671
|
-
|
|
672
|
-
if (!isIncluded(rel)) continue;
|
|
673
|
-
|
|
674
|
-
const stat = await fsp.stat(full);
|
|
675
|
-
result.set(rel, {
|
|
676
|
-
rel,
|
|
677
|
-
localPath: full,
|
|
678
|
-
size: stat.size,
|
|
679
|
-
mtimeMs: stat.mtimeMs,
|
|
680
|
-
isText: isTextFile(rel),
|
|
681
|
-
isMedia: isMediaFile(rel),
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
scanned += 1;
|
|
685
|
-
const chunk = IS_VERBOSE ? 1 : SCAN_CHUNK;
|
|
686
|
-
if (scanned === 1 || scanned % chunk === 0) {
|
|
687
|
-
updateProgress2("Scan local: ", scanned, 0, rel);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
await recurse(root);
|
|
694
|
-
|
|
695
|
-
if (scanned > 0) {
|
|
696
|
-
updateProgress2("Scan local: ", scanned, 0, "fertig");
|
|
697
|
-
process.stdout.write("\n");
|
|
698
|
-
progressActive = false;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
return result;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Plain walker für Bypass (ignoriert INCLUDE/EXCLUDE)
|
|
705
|
-
async function walkLocalPlain(root) {
|
|
706
|
-
const result = new Map();
|
|
707
|
-
|
|
708
|
-
async function recurse(current) {
|
|
709
|
-
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
710
|
-
for (const entry of entries) {
|
|
711
|
-
const full = path.join(current, entry.name);
|
|
712
|
-
if (entry.isDirectory()) {
|
|
713
|
-
await recurse(full);
|
|
714
|
-
} else if (entry.isFile()) {
|
|
715
|
-
const rel = toPosix(path.relative(root, full));
|
|
716
|
-
result.set(rel, {
|
|
717
|
-
rel,
|
|
718
|
-
localPath: full,
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
await recurse(root);
|
|
725
|
-
return result;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// ---------------------------------------------------------------------------
|
|
729
|
-
// Remote walker (recursive, all subdirectories) – respects INCLUDE/EXCLUDE
|
|
730
|
-
// ---------------------------------------------------------------------------
|
|
731
|
-
|
|
732
|
-
async function walkRemote(sftp, remoteRoot) {
|
|
733
|
-
const result = new Map();
|
|
734
|
-
let scanned = 0;
|
|
735
|
-
|
|
736
|
-
async function recurse(remoteDir, prefix) {
|
|
737
|
-
const items = await sftp.list(remoteDir);
|
|
738
|
-
|
|
739
|
-
for (const item of items) {
|
|
740
|
-
if (!item.name || item.name === "." || item.name === "..") continue;
|
|
741
|
-
|
|
742
|
-
const full = path.posix.join(remoteDir, item.name);
|
|
743
|
-
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
744
|
-
|
|
745
|
-
// Include/Exclude-Regeln auch auf Remote anwenden
|
|
746
|
-
if (!isIncluded(rel)) continue;
|
|
747
|
-
|
|
748
|
-
if (item.type === "d") {
|
|
749
|
-
await recurse(full, rel);
|
|
750
|
-
} else {
|
|
751
|
-
result.set(rel, {
|
|
752
|
-
rel,
|
|
753
|
-
remotePath: full,
|
|
754
|
-
size: Number(item.size),
|
|
755
|
-
modifyTime: item.modifyTime ?? 0,
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
scanned += 1;
|
|
759
|
-
const chunk = IS_VERBOSE ? 1 : SCAN_CHUNK;
|
|
760
|
-
if (scanned === 1 || scanned % chunk === 0) {
|
|
761
|
-
updateProgress2("Scan remote: ", scanned, 0, rel);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
await recurse(remoteRoot, "");
|
|
768
|
-
|
|
769
|
-
if (scanned > 0) {
|
|
770
|
-
updateProgress2("Scan remote: ", scanned, 0, "fertig");
|
|
771
|
-
process.stdout.write("\n");
|
|
772
|
-
progressActive = false;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
return result;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// Plain walker für Bypass (ignoriert INCLUDE/EXCLUDE)
|
|
779
|
-
async function walkRemotePlain(sftp, remoteRoot) {
|
|
780
|
-
const result = new Map();
|
|
781
|
-
|
|
782
|
-
async function recurse(remoteDir, prefix) {
|
|
783
|
-
const items = await sftp.list(remoteDir);
|
|
784
|
-
|
|
785
|
-
for (const item of items) {
|
|
786
|
-
if (!item.name || item.name === "." || item.name === "..") continue;
|
|
787
|
-
|
|
788
|
-
const full = path.posix.join(remoteDir, item.name);
|
|
789
|
-
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
790
|
-
|
|
791
|
-
if (item.type === "d") {
|
|
792
|
-
await recurse(full, rel);
|
|
793
|
-
} else {
|
|
794
|
-
result.set(rel, {
|
|
795
|
-
rel,
|
|
796
|
-
remotePath: full,
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
await recurse(remoteRoot, "");
|
|
803
|
-
return result;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// ---------------------------------------------------------------------------
|
|
807
|
-
// Hash helper for binaries (streaming, memory-efficient)
|
|
808
|
-
// ---------------------------------------------------------------------------
|
|
809
|
-
|
|
810
|
-
function hashLocalFile(filePath) {
|
|
811
|
-
return new Promise((resolve, reject) => {
|
|
812
|
-
const hash = createHash("sha256");
|
|
813
|
-
const stream = fs.createReadStream(filePath);
|
|
814
|
-
stream.on("error", reject);
|
|
815
|
-
stream.on("data", (chunk) => hash.update(chunk));
|
|
816
|
-
stream.on("end", () => resolve(hash.digest("hex")));
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
async function hashRemoteFile(sftp, remotePath) {
|
|
821
|
-
const hash = createHash("sha256");
|
|
822
|
-
|
|
823
|
-
const writable = new Writable({
|
|
824
|
-
write(chunk, enc, cb) {
|
|
825
|
-
hash.update(chunk);
|
|
826
|
-
cb();
|
|
827
|
-
},
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
await sftp.get(remotePath, writable);
|
|
831
|
-
return hash.digest("hex");
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// Cache-aware Helpers
|
|
835
|
-
async function getLocalHash(rel, meta) {
|
|
836
|
-
const key = cacheKey(rel);
|
|
837
|
-
const cached = CACHE.local[key];
|
|
838
|
-
if (
|
|
839
|
-
cached &&
|
|
840
|
-
cached.size === meta.size &&
|
|
841
|
-
cached.mtimeMs === meta.mtimeMs &&
|
|
842
|
-
cached.hash
|
|
843
|
-
) {
|
|
844
|
-
return cached.hash;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
const hash = await hashLocalFile(meta.localPath);
|
|
848
|
-
CACHE.local[key] = {
|
|
849
|
-
size: meta.size,
|
|
850
|
-
mtimeMs: meta.mtimeMs,
|
|
851
|
-
hash,
|
|
852
|
-
};
|
|
853
|
-
await markCacheDirty();
|
|
854
|
-
return hash;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
async function getRemoteHash(rel, meta, sftp) {
|
|
858
|
-
const key = cacheKey(rel);
|
|
859
|
-
const cached = CACHE.remote[key];
|
|
860
|
-
if (
|
|
861
|
-
cached &&
|
|
862
|
-
cached.size === meta.size &&
|
|
863
|
-
cached.modifyTime === meta.modifyTime &&
|
|
864
|
-
cached.hash
|
|
865
|
-
) {
|
|
866
|
-
return cached.hash;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
const hash = await hashRemoteFile(sftp, meta.remotePath);
|
|
870
|
-
CACHE.remote[key] = {
|
|
871
|
-
size: meta.size,
|
|
872
|
-
modifyTime: meta.modifyTime,
|
|
873
|
-
hash,
|
|
874
|
-
};
|
|
875
|
-
await markCacheDirty();
|
|
876
|
-
return hash;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// ---------------------------------------------------------------------------
|
|
880
|
-
// SFTP error explanation (for clearer messages)
|
|
881
|
-
// ---------------------------------------------------------------------------
|
|
882
|
-
|
|
883
|
-
function describeSftpError(err) {
|
|
884
|
-
if (!err) return "";
|
|
885
|
-
|
|
886
|
-
const code = err.code || err.errno || "";
|
|
887
|
-
const msg = (err.message || "").toLowerCase();
|
|
888
|
-
|
|
889
|
-
// Netzwerk / DNS
|
|
890
|
-
if (code === "ENOTFOUND") {
|
|
891
|
-
return "Host not found (ENOTFOUND) – Check hostname or DNS entry.";
|
|
892
|
-
}
|
|
893
|
-
if (code === "EHOSTUNREACH") {
|
|
894
|
-
return "Host not reachable (EHOSTUNREACH) – Check network/firewall.";
|
|
895
|
-
}
|
|
896
|
-
if (code === "ECONNREFUSED") {
|
|
897
|
-
return "Connection refused (ECONNREFUSED) – Check the port or SSH service.";
|
|
898
|
-
}
|
|
899
|
-
if (code === "ECONNRESET") {
|
|
900
|
-
return "Connection was reset by the server (ECONNRESET).";
|
|
901
|
-
}
|
|
902
|
-
if (code === "ETIMEDOUT") {
|
|
903
|
-
return "Connection timeout (ETIMEDOUT) – Server is not responding or is blocked.";
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
// Auth / Authorisations
|
|
907
|
-
if (msg.includes("all configured authentication methods failed")) {
|
|
908
|
-
return "Authentication failed – check your username/password or SSH keys.";
|
|
909
|
-
}
|
|
910
|
-
if (msg.includes("permission denied")) {
|
|
911
|
-
return "Access denied – check permissions on the server.";
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Fallback
|
|
915
|
-
return "";
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// ---------------------------------------------------------------------------
|
|
919
|
-
// Bypass-only Mode (sidecar-upload / sidecar-download ohne normalen Sync)
|
|
920
|
-
// ---------------------------------------------------------------------------
|
|
921
|
-
|
|
922
|
-
async function collectUploadTargets() {
|
|
923
|
-
const all = await walkLocalPlain(CONNECTION.sidecarLocalRoot);
|
|
924
|
-
const results = [];
|
|
925
|
-
|
|
926
|
-
for (const [rel, meta] of all.entries()) {
|
|
927
|
-
if (matchesAny(UPLOAD_LIST, rel)) {
|
|
928
|
-
const remotePath = path.posix.join(CONNECTION.sidecarRemoteRoot, rel);
|
|
929
|
-
results.push({
|
|
930
|
-
rel,
|
|
931
|
-
localPath: meta.localPath,
|
|
932
|
-
remotePath,
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
return results;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
async function collectDownloadTargets(sftp) {
|
|
941
|
-
const all = await walkRemotePlain(sftp, CONNECTION.sidecarRemoteRoot);
|
|
942
|
-
const results = [];
|
|
943
|
-
|
|
944
|
-
for (const [rel, meta] of all.entries()) {
|
|
945
|
-
if (matchesAny(DOWNLOAD_LIST, rel)) {
|
|
946
|
-
const localPath = path.join(CONNECTION.sidecarLocalRoot, rel);
|
|
947
|
-
results.push({
|
|
948
|
-
rel,
|
|
949
|
-
remotePath: meta.remotePath,
|
|
950
|
-
localPath,
|
|
951
|
-
});
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
return results;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
async function performBypassOnly(sftp) {
|
|
959
|
-
log("");
|
|
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
|
-
}
|
|
971
|
-
|
|
972
|
-
if (RUN_UPLOAD_LIST) {
|
|
973
|
-
log("");
|
|
974
|
-
log(pc.bold(pc.cyan("⬆️ Upload-Bypass (sidecar-upload) …")));
|
|
975
|
-
const targets = await collectUploadTargets();
|
|
976
|
-
log(`${tab_a()}→ ${targets.length} files from uploadList`);
|
|
977
|
-
|
|
978
|
-
if (!DRY_RUN) {
|
|
979
|
-
await runTasks(
|
|
980
|
-
targets,
|
|
981
|
-
CONNECTION.workers,
|
|
982
|
-
async ({ localPath, remotePath, rel }) => {
|
|
983
|
-
const remoteDir = path.posix.dirname(remotePath);
|
|
984
|
-
try {
|
|
985
|
-
await sftp.mkdir(remoteDir, true);
|
|
986
|
-
} catch {
|
|
987
|
-
// Directory may already exist
|
|
988
|
-
}
|
|
989
|
-
await sftp.put(localPath, remotePath);
|
|
990
|
-
vlog(`${tab_a()}${ADD} Uploaded (bypass): ${rel}`);
|
|
991
|
-
},
|
|
992
|
-
"Bypass Uploads"
|
|
993
|
-
);
|
|
994
|
-
} else {
|
|
995
|
-
for (const t of targets) {
|
|
996
|
-
log(`${tab_a()}${ADD} (DRY-RUN) Upload: ${t.rel}`);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
if (RUN_DOWNLOAD_LIST) {
|
|
1002
|
-
log("");
|
|
1003
|
-
log(pc.bold(pc.cyan("⬇️ Download-Bypass (sidecar-download) …")));
|
|
1004
|
-
const targets = await collectDownloadTargets(sftp);
|
|
1005
|
-
log(`${tab_a()}→ ${targets.length} files from downloadList`);
|
|
1006
|
-
|
|
1007
|
-
if (!DRY_RUN) {
|
|
1008
|
-
await runTasks(
|
|
1009
|
-
targets,
|
|
1010
|
-
CONNECTION.workers,
|
|
1011
|
-
async ({ remotePath, localPath, rel }) => {
|
|
1012
|
-
const localDir = path.dirname(localPath);
|
|
1013
|
-
await fsp.mkdir(localDir, { recursive: true });
|
|
1014
|
-
await sftp.get(remotePath, localPath);
|
|
1015
|
-
vlog(`${tab_a()}${CHA} Downloaded (bypass): ${rel}`);
|
|
1016
|
-
},
|
|
1017
|
-
"Bypass Downloads"
|
|
1018
|
-
);
|
|
1019
|
-
} else {
|
|
1020
|
-
for (const t of targets) {
|
|
1021
|
-
log(`${tab_a()}${CHA} (DRY-RUN) Download: ${t.rel}`);
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
log("");
|
|
1027
|
-
log(pc.bold(pc.green("✅ Bypass-only run finished.")));
|
|
165
|
+
console.log(" --verbose Enable verbose logging");
|
|
166
|
+
console.log(" --laconic Minimal logging (overrides verbose)");
|
|
167
|
+
console.log(
|
|
168
|
+
" --config, -c <file> Use custom config file (default: ./sync.config.json)"
|
|
169
|
+
);
|
|
170
|
+
console.log(" --help, -h Show this help");
|
|
171
|
+
console.log("");
|
|
172
|
+
/* eslint-enable no-console */
|
|
1028
173
|
}
|
|
1029
174
|
|
|
1030
175
|
// ---------------------------------------------------------------------------
|
|
1031
176
|
// MAIN
|
|
1032
177
|
// ---------------------------------------------------------------------------
|
|
1033
178
|
|
|
1034
|
-
async function initLogFile() {
|
|
1035
|
-
if (!LOG_FILE) return;
|
|
1036
|
-
const dir = path.dirname(LOG_FILE);
|
|
1037
|
-
await fsp.mkdir(dir, { recursive: true });
|
|
1038
|
-
LOG_STREAM = fs.createWriteStream(LOG_FILE, {
|
|
1039
|
-
flags: "w",
|
|
1040
|
-
encoding: "utf8",
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
179
|
async function main() {
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
log(`${tab_a()}Worker: ${CONNECTION.workers}`);
|
|
1055
|
-
log(
|
|
1056
|
-
`${tab_a()}Host: ${pc.green(CONNECTION.host)}:${pc.green(CONNECTION.port)}`
|
|
1057
|
-
);
|
|
1058
|
-
log(`${tab_a()}Local: ${pc.green(CONNECTION.localRoot)}`);
|
|
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
|
-
}
|
|
1064
|
-
if (DRY_RUN) log(pc.yellow(`${tab_a()}Mode: DRY-RUN (no changes)`));
|
|
1065
|
-
if (SKIP_SYNC) log(pc.yellow(`${tab_a()}Mode: SKIP-SYNC (bypass only)`));
|
|
1066
|
-
if (RUN_UPLOAD_LIST || RUN_DOWNLOAD_LIST) {
|
|
1067
|
-
log(
|
|
1068
|
-
pc.blue(
|
|
1069
|
-
`${tab_a()}Extra: ${
|
|
1070
|
-
RUN_UPLOAD_LIST ? "sidecar-upload " : ""
|
|
1071
|
-
}${RUN_DOWNLOAD_LIST ? "sidecar-download" : ""}`
|
|
1072
|
-
)
|
|
1073
|
-
);
|
|
1074
|
-
}
|
|
1075
|
-
if (CLEANUP_EMPTY_DIRS) {
|
|
1076
|
-
log(`${tab_a()}Cleanup empty dirs: ${pc.green("enabled")}`);
|
|
1077
|
-
}
|
|
1078
|
-
if (LOG_FILE) {
|
|
1079
|
-
log(`${tab_a()}LogFile: ${pc.cyan(LOG_FILE)}`);
|
|
1080
|
-
}
|
|
1081
|
-
log(hr1());
|
|
1082
|
-
|
|
1083
|
-
const sftp = new SftpClient();
|
|
1084
|
-
let connected = false;
|
|
1085
|
-
|
|
1086
|
-
const toAdd = [];
|
|
1087
|
-
const toUpdate = [];
|
|
1088
|
-
const toDelete = [];
|
|
1089
|
-
|
|
1090
|
-
try {
|
|
1091
|
-
log("");
|
|
1092
|
-
log(pc.cyan("🔌 Connecting to SFTP server …"));
|
|
1093
|
-
await sftp.connect({
|
|
1094
|
-
host: CONNECTION.host,
|
|
1095
|
-
port: CONNECTION.port,
|
|
1096
|
-
username: CONNECTION.user,
|
|
1097
|
-
password: CONNECTION.password,
|
|
1098
|
-
});
|
|
1099
|
-
connected = true;
|
|
1100
|
-
log(pc.green(`${tab_a()}✔ Connected to SFTP.`));
|
|
1101
|
-
|
|
1102
|
-
if (!SKIP_SYNC && !fs.existsSync(CONNECTION.localRoot)) {
|
|
1103
|
-
console.error(
|
|
1104
|
-
pc.red("❌ Local root does not exist:"),
|
|
1105
|
-
CONNECTION.localRoot
|
|
1106
|
-
);
|
|
1107
|
-
process.exit(1);
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// -------------------------------------------------------------
|
|
1111
|
-
// SKIP-SYNC-Modus → nur Sidecar-Listen
|
|
1112
|
-
// -------------------------------------------------------------
|
|
1113
|
-
if (SKIP_SYNC) {
|
|
1114
|
-
await performBypassOnly(sftp);
|
|
1115
|
-
const duration = ((Date.now() - start) / 1000).toFixed(2);
|
|
1116
|
-
log("");
|
|
1117
|
-
log(pc.bold(pc.cyan("📊 Summary (bypass only):")));
|
|
1118
|
-
log(`${tab_a()}Duration: ${pc.green(duration + " s")}`);
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
// -------------------------------------------------------------
|
|
1123
|
-
// Normaler Sync (inkl. evtl. paralleler Sidecar-Excludes)
|
|
1124
|
-
// -------------------------------------------------------------
|
|
1125
|
-
|
|
1126
|
-
// Phase 1 – mit exakt einer Leerzeile davor
|
|
1127
|
-
log("");
|
|
1128
|
-
log(pc.bold(pc.cyan("📥 Phase 1: Scan local files …")));
|
|
1129
|
-
const local = await walkLocal(CONNECTION.localRoot);
|
|
1130
|
-
log(`${tab_a()}→ ${local.size} local files`);
|
|
1131
|
-
|
|
1132
|
-
if (AUTO_EXCLUDED.size > 0) {
|
|
1133
|
-
log("");
|
|
1134
|
-
log(pc.dim(" Auto-excluded (sidecar upload/download):"));
|
|
1135
|
-
[...AUTO_EXCLUDED].sort().forEach((file) => {
|
|
1136
|
-
log(pc.dim(`${tab_a()} - ${file}`));
|
|
1137
|
-
});
|
|
1138
|
-
log("");
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// Phase 2 – auch mit einer Leerzeile davor
|
|
1142
|
-
log("");
|
|
1143
|
-
log(pc.bold(pc.cyan("📤 Phase 2: Scan remote files …")));
|
|
1144
|
-
const remote = await walkRemote(sftp, CONNECTION.remoteRoot);
|
|
1145
|
-
log(`${tab_a()}→ ${remote.size} remote files`);
|
|
1146
|
-
log("");
|
|
1147
|
-
|
|
1148
|
-
const localKeys = new Set(local.keys());
|
|
1149
|
-
const remoteKeys = new Set(remote.keys());
|
|
1150
|
-
|
|
1151
|
-
log(pc.bold(pc.cyan("🔎 Phase 3: Compare & decide …")));
|
|
1152
|
-
const totalToCheck = localKeys.size;
|
|
1153
|
-
let checkedCount = 0;
|
|
1154
|
-
|
|
1155
|
-
// Analysis: just decide, don't upload/delete anything yet
|
|
1156
|
-
for (const rel of localKeys) {
|
|
1157
|
-
checkedCount += 1;
|
|
1158
|
-
|
|
1159
|
-
const chunk = IS_VERBOSE ? 1 : ANALYZE_CHUNK;
|
|
1160
|
-
if (
|
|
1161
|
-
checkedCount === 1 || // immediate first issue
|
|
1162
|
-
checkedCount % chunk === 0 ||
|
|
1163
|
-
checkedCount === totalToCheck
|
|
1164
|
-
) {
|
|
1165
|
-
updateProgress2("Analyse: ", checkedCount, totalToCheck, rel);
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
const l = local.get(rel);
|
|
1169
|
-
const r = remote.get(rel);
|
|
1170
|
-
const remotePath = path.posix.join(CONNECTION.remoteRoot, rel);
|
|
1171
|
-
|
|
1172
|
-
if (!r) {
|
|
1173
|
-
toAdd.push({ rel, local: l, remotePath });
|
|
1174
|
-
if (!IS_LACONIC) {
|
|
1175
|
-
log(`${ADD} ${pc.green("New:")} ${rel}`);
|
|
1176
|
-
}
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
// 1. size comparison
|
|
1181
|
-
if (l.size !== r.size) {
|
|
1182
|
-
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
1183
|
-
if (!IS_LACONIC) {
|
|
1184
|
-
log(`${CHA} ${pc.yellow("Size changed:")} ${rel}`);
|
|
1185
|
-
}
|
|
1186
|
-
continue;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
// 2. content comparison
|
|
1190
|
-
if (l.isText) {
|
|
1191
|
-
// Text file: Read & compare in full
|
|
1192
|
-
const [localBuf, remoteBuf] = await Promise.all([
|
|
1193
|
-
fsp.readFile(l.localPath),
|
|
1194
|
-
sftp.get(r.remotePath),
|
|
1195
|
-
]);
|
|
1196
|
-
|
|
1197
|
-
const localStr = localBuf.toString("utf8");
|
|
1198
|
-
const remoteStr = (
|
|
1199
|
-
Buffer.isBuffer(remoteBuf) ? remoteBuf : Buffer.from(remoteBuf)
|
|
1200
|
-
).toString("utf8");
|
|
1201
|
-
|
|
1202
|
-
if (localStr === remoteStr) {
|
|
1203
|
-
vlog(`${tab_a()}${pc.dim("✓ Unchanged (Text):")} ${rel}`);
|
|
1204
|
-
continue;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
if (IS_VERBOSE) {
|
|
1208
|
-
const diff = diffWords(remoteStr, localStr);
|
|
1209
|
-
const blocks = diff.filter((d) => d.added || d.removed).length;
|
|
1210
|
-
vlog(`${tab_a()}${CHA} Text difference (${blocks} blocks) in ${rel}`);
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
1214
|
-
if (!IS_LACONIC) {
|
|
1215
|
-
log(
|
|
1216
|
-
`${tab_a()}${CHA} ${pc.yellow("Content changed (Text):")} ${rel}`
|
|
1217
|
-
);
|
|
1218
|
-
}
|
|
1219
|
-
} else {
|
|
1220
|
-
// Binary: Hash comparison with cache
|
|
1221
|
-
const localMeta = l;
|
|
1222
|
-
const remoteMeta = r;
|
|
1223
|
-
|
|
1224
|
-
const [localHash, remoteHash] = await Promise.all([
|
|
1225
|
-
getLocalHash(rel, localMeta),
|
|
1226
|
-
getRemoteHash(rel, remoteMeta, sftp),
|
|
1227
|
-
]);
|
|
1228
|
-
|
|
1229
|
-
if (localHash === remoteHash) {
|
|
1230
|
-
vlog(`${tab_a()}${pc.dim("✓ Unchanged (binary, hash):")} ${rel}`);
|
|
1231
|
-
continue;
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
if (IS_VERBOSE) {
|
|
1235
|
-
vlog(`${tab_a()}${CHA} Hash different (binary): ${rel}`);
|
|
1236
|
-
vlog(`${tab_b()}local: ${localHash}`);
|
|
1237
|
-
vlog(`${tab_b()}remote: ${remoteHash}`);
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
toUpdate.push({ rel, local: l, remote: r, remotePath });
|
|
1241
|
-
if (!IS_LACONIC) {
|
|
1242
|
-
log(`${CHA} ${pc.yellow("Content changed (Binary):")} ${rel}`);
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
// Wenn Phase 3 nichts gefunden hat, explizit sagen
|
|
1248
|
-
if (toAdd.length === 0 && toUpdate.length === 0) {
|
|
1249
|
-
log("");
|
|
1250
|
-
log(`${tab_a()}No differences found. Everything is up to date.`);
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
log("");
|
|
1254
|
-
log(pc.bold(pc.cyan("🧹 Phase 4: Removing orphaned remote files …")));
|
|
1255
|
-
for (const rel of remoteKeys) {
|
|
1256
|
-
if (!localKeys.has(rel)) {
|
|
1257
|
-
const r = remote.get(rel);
|
|
1258
|
-
toDelete.push({ rel, remotePath: r.remotePath });
|
|
1259
|
-
if (!IS_LACONIC) {
|
|
1260
|
-
log(`${tab_a()}${DEL} ${pc.red("Remove:")} ${rel}`);
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// Auch für Phase 4 eine „nix zu tun“-Meldung
|
|
1266
|
-
if (toDelete.length === 0) {
|
|
1267
|
-
log(`${tab_a()}No orphaned remote files found.`);
|
|
1268
|
-
}
|
|
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
|
-
|
|
1284
|
-
// -------------------------------------------------------------------
|
|
1285
|
-
// Phase 5: Execute changes (parallel, worker-based)
|
|
1286
|
-
// -------------------------------------------------------------------
|
|
1287
|
-
|
|
1288
|
-
if (!DRY_RUN) {
|
|
1289
|
-
log("");
|
|
1290
|
-
log(pc.bold(pc.cyan("🚚 Phase 5: Apply changes …")));
|
|
1291
|
-
|
|
1292
|
-
// Upload new files
|
|
1293
|
-
await runTasks(
|
|
1294
|
-
toAdd,
|
|
1295
|
-
CONNECTION.workers,
|
|
1296
|
-
async ({ local: l, remotePath }) => {
|
|
1297
|
-
// Verzeichnisse sollten bereits existieren – mkdir hier nur als Fallback
|
|
1298
|
-
const remoteDir = path.posix.dirname(remotePath);
|
|
1299
|
-
try {
|
|
1300
|
-
await sftp.mkdir(remoteDir, true);
|
|
1301
|
-
} catch {
|
|
1302
|
-
// Directory may already exist.
|
|
1303
|
-
}
|
|
1304
|
-
await sftp.put(l.localPath, remotePath);
|
|
1305
|
-
},
|
|
1306
|
-
"Uploads (new)"
|
|
1307
|
-
);
|
|
1308
|
-
|
|
1309
|
-
// Updates
|
|
1310
|
-
await runTasks(
|
|
1311
|
-
toUpdate,
|
|
1312
|
-
CONNECTION.workers,
|
|
1313
|
-
async ({ local: l, remotePath }) => {
|
|
1314
|
-
const remoteDir = path.posix.dirname(remotePath);
|
|
1315
|
-
try {
|
|
1316
|
-
await sftp.mkdir(remoteDir, true);
|
|
1317
|
-
} catch {
|
|
1318
|
-
// Directory may already exist.
|
|
1319
|
-
}
|
|
1320
|
-
await sftp.put(l.localPath, remotePath);
|
|
1321
|
-
},
|
|
1322
|
-
"Uploads (update)"
|
|
1323
|
-
);
|
|
1324
|
-
|
|
1325
|
-
// Deletes
|
|
1326
|
-
await runTasks(
|
|
1327
|
-
toDelete,
|
|
1328
|
-
CONNECTION.workers,
|
|
1329
|
-
async ({ remotePath }) => {
|
|
1330
|
-
try {
|
|
1331
|
-
await sftp.delete(remotePath);
|
|
1332
|
-
} catch (e) {
|
|
1333
|
-
console.error(
|
|
1334
|
-
pc.red(" ⚠️ Error during deletion:"),
|
|
1335
|
-
remotePath,
|
|
1336
|
-
e.message || e
|
|
1337
|
-
);
|
|
1338
|
-
}
|
|
1339
|
-
},
|
|
1340
|
-
"Deletes"
|
|
1341
|
-
);
|
|
1342
|
-
} else {
|
|
1343
|
-
log("");
|
|
1344
|
-
log(
|
|
1345
|
-
pc.yellow(
|
|
1346
|
-
"💡 DRY-RUN: Connection tested, no files transferred or deleted."
|
|
1347
|
-
)
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
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
|
-
|
|
1358
|
-
const duration = ((Date.now() - start) / 1000).toFixed(2);
|
|
1359
|
-
|
|
1360
|
-
// Write cache safely at the end
|
|
1361
|
-
await saveCache(true);
|
|
1362
|
-
|
|
1363
|
-
// Summary
|
|
1364
|
-
log(hr1());
|
|
1365
|
-
log("");
|
|
1366
|
-
log(pc.bold(pc.cyan("📊 Summary:")));
|
|
1367
|
-
log(`${tab_a()}Duration: ${pc.green(duration + " s")}`);
|
|
1368
|
-
log(`${tab_a()}${ADD} Added : ${toAdd.length}`);
|
|
1369
|
-
log(`${tab_a()}${CHA} Changed: ${toUpdate.length}`);
|
|
1370
|
-
log(`${tab_a()}${DEL} Deleted: ${toDelete.length}`);
|
|
1371
|
-
if (AUTO_EXCLUDED.size > 0) {
|
|
1372
|
-
log(
|
|
1373
|
-
`${tab_a()}${EXC} Excluded via sidecar upload/download: ${
|
|
1374
|
-
AUTO_EXCLUDED.size
|
|
1375
|
-
}`
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
if (toAdd.length || toUpdate.length || toDelete.length) {
|
|
1379
|
-
log("");
|
|
1380
|
-
log("📄 Changes:");
|
|
1381
|
-
[...toAdd.map((t) => t.rel)]
|
|
1382
|
-
.sort()
|
|
1383
|
-
.forEach((f) => console.log(`${tab_a()}${ADD} ${f}`));
|
|
1384
|
-
[...toUpdate.map((t) => t.rel)]
|
|
1385
|
-
.sort()
|
|
1386
|
-
.forEach((f) => console.log(`${tab_a()}${CHA} ${f}`));
|
|
1387
|
-
[...toDelete.map((t) => t.rel)]
|
|
1388
|
-
.sort()
|
|
1389
|
-
.forEach((f) => console.log(`${tab_a()}${DEL} ${f}`));
|
|
1390
|
-
} else {
|
|
1391
|
-
log("");
|
|
1392
|
-
log("No changes.");
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
log("");
|
|
1396
|
-
log(pc.bold(pc.green("✅ Sync complete.")));
|
|
1397
|
-
} catch (err) {
|
|
1398
|
-
const hint = describeSftpError(err);
|
|
1399
|
-
elog(pc.red("❌ Synchronisation error:"), err.message || err);
|
|
1400
|
-
if (hint) {
|
|
1401
|
-
wlog(pc.yellow(`${tab_a()}Mögliche Ursache:`), hint);
|
|
1402
|
-
}
|
|
1403
|
-
if (IS_VERBOSE) {
|
|
1404
|
-
// Vollständiges Error-Objekt nur in verbose anzeigen
|
|
1405
|
-
console.error(err);
|
|
1406
|
-
}
|
|
1407
|
-
process.exitCode = 1;
|
|
1408
|
-
try {
|
|
1409
|
-
await saveCache(true);
|
|
1410
|
-
} catch {
|
|
1411
|
-
// ignore
|
|
1412
|
-
}
|
|
1413
|
-
} finally {
|
|
1414
|
-
try {
|
|
1415
|
-
if (connected) {
|
|
1416
|
-
await sftp.end();
|
|
1417
|
-
log(pc.green(`${tab_a()}✔ Connection closed.`));
|
|
1418
|
-
}
|
|
1419
|
-
} catch (e) {
|
|
1420
|
-
wlog(
|
|
1421
|
-
pc.yellow("⚠️ Could not close SFTP connection cleanly:"),
|
|
1422
|
-
e.message || e
|
|
1423
|
-
);
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
// Abschlusslinie + Leerzeile **vor** dem Schließen des Logfiles
|
|
1427
|
-
log(hr2());
|
|
1428
|
-
log("");
|
|
180
|
+
const app = new SftpPushSyncApp({
|
|
181
|
+
target: TARGET,
|
|
182
|
+
dryRun: DRY_RUN,
|
|
183
|
+
runUploadList: RUN_UPLOAD_LIST,
|
|
184
|
+
runDownloadList: RUN_DOWNLOAD_LIST,
|
|
185
|
+
skipSync: SKIP_SYNC,
|
|
186
|
+
cliLogLevel,
|
|
187
|
+
configPath,
|
|
188
|
+
});
|
|
1429
189
|
|
|
1430
|
-
|
|
1431
|
-
LOG_STREAM.end();
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
190
|
+
await app.run();
|
|
1434
191
|
}
|
|
1435
192
|
|
|
1436
|
-
main()
|
|
193
|
+
main().catch((err) => {
|
|
194
|
+
console.error(pc.red("❌ Unhandled error in sftp-push-sync:"), err?.message || err);
|
|
195
|
+
if (process.env.DEBUG) {
|
|
196
|
+
console.error(err);
|
|
197
|
+
}
|
|
198
|
+
process.exit(1);
|
|
199
|
+
});
|