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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * SyncLogger.mjs
3
+ *
4
+ * @author Carsten Nichte, 2025 / https://carsten-nichte.de/
5
+ *
6
+ */
7
+ // src/core/SyncLogger.mjs
8
+ import fs from "fs";
9
+ import fsp from "fs/promises";
10
+ import path from "path";
11
+
12
+ /**
13
+ * Very small logger: schreibt alles in eine Logdatei
14
+ * und entfernt ANSI-Farbcodes.
15
+ */
16
+ export class SyncLogger {
17
+ constructor(filePath) {
18
+ this.filePath = filePath;
19
+ this.stream = null;
20
+ }
21
+
22
+ async init() {
23
+ if (!this.filePath || this.stream) return;
24
+
25
+ const dir = path.dirname(this.filePath);
26
+ await fsp.mkdir(dir, { recursive: true });
27
+
28
+ this.stream = fs.createWriteStream(this.filePath, {
29
+ flags: "w",
30
+ encoding: "utf8",
31
+ });
32
+ }
33
+
34
+ writeLine(line) {
35
+ if (!this.stream) return;
36
+ const text = typeof line === "string" ? line : String(line);
37
+ const clean = text.replace(/\x1b\[[0-9;]*m/g, "");
38
+
39
+ try {
40
+ this.stream.write(clean + "\n");
41
+ } catch {
42
+ // Stream schon zu → ignorieren
43
+ }
44
+ }
45
+
46
+ close() {
47
+ if (this.stream) {
48
+ this.stream.end();
49
+ this.stream = null;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * compare.mjs
3
+ *
4
+ * @author Carsten Nichte, 2025 / https://carsten-nichte.de/
5
+ *
6
+ */
7
+ // src/helpers/compare.mjs
8
+ import fsp from "fs/promises";
9
+ import path from "path";
10
+
11
+ /**
12
+ * Analysiert Unterschiede zwischen local- und remote-Maps.
13
+ *
14
+ * Erwartete Struktur:
15
+ * local: Map<rel, { rel, localPath, size, mtimeMs, isText? }>
16
+ * remote: Map<rel, { rel, remotePath, size, modifyTime }>
17
+ *
18
+ * Optionen:
19
+ * - remoteRoot: Basis-Pfad auf dem Server
20
+ * - sftp: ssh2-sftp-client Instanz
21
+ * - getLocalHash / getRemoteHash: from createHashCache
22
+ * - analyzeChunk: Progress-Schrittgröße
23
+ * - updateProgress(prefix, current, total, rel): optional
24
+ */
25
+ export async function analyseDifferences({
26
+ local,
27
+ remote,
28
+ remoteRoot,
29
+ sftp,
30
+ getLocalHash,
31
+ getRemoteHash,
32
+ analyzeChunk = 10,
33
+ updateProgress,
34
+ }) {
35
+ const toAdd = [];
36
+ const toUpdate = [];
37
+
38
+ const localKeys = new Set(local.keys());
39
+ const totalToCheck = localKeys.size;
40
+ let checked = 0;
41
+
42
+ for (const rel of localKeys) {
43
+ checked += 1;
44
+
45
+ if (
46
+ updateProgress &&
47
+ (checked === 1 || checked % analyzeChunk === 0 || checked === totalToCheck)
48
+ ) {
49
+ updateProgress("Analyse: ", checked, totalToCheck, rel);
50
+ }
51
+
52
+ const l = local.get(rel);
53
+ const r = remote.get(rel);
54
+ const remotePath = path.posix.join(remoteRoot, rel);
55
+
56
+ // Datei existiert nur lokal → New
57
+ if (!r) {
58
+ toAdd.push({ rel, local: l, remotePath });
59
+ continue;
60
+ }
61
+
62
+ // 1. Size-Vergleich
63
+ if (l.size !== r.size) {
64
+ toUpdate.push({ rel, local: l, remote: r, remotePath });
65
+ continue;
66
+ }
67
+
68
+ // 2. Content-Vergleich
69
+ if (l.isText) {
70
+ // Text-Datei: vollständiger inhaltlicher Vergleich
71
+ const [localBuf, remoteBuf] = await Promise.all([
72
+ fsp.readFile(l.localPath),
73
+ sftp.get(r.remotePath),
74
+ ]);
75
+
76
+ const localStr = localBuf.toString("utf8");
77
+ const remoteStr = (
78
+ Buffer.isBuffer(remoteBuf) ? remoteBuf : Buffer.from(remoteBuf)
79
+ ).toString("utf8");
80
+
81
+ if (localStr !== remoteStr) {
82
+ toUpdate.push({ rel, local: l, remote: r, remotePath });
83
+ }
84
+ } else {
85
+ // Binary: Hash-Vergleich mit Cache
86
+ if (!getLocalHash || !getRemoteHash) {
87
+ // Fallback: wenn kein Hash-Cache übergeben wurde, treat as changed
88
+ toUpdate.push({ rel, local: l, remote: r, remotePath });
89
+ continue;
90
+ }
91
+
92
+ const [localHash, remoteHash] = await Promise.all([
93
+ getLocalHash(rel, l),
94
+ getRemoteHash(rel, r, sftp),
95
+ ]);
96
+
97
+ if (localHash !== remoteHash) {
98
+ toUpdate.push({ rel, local: l, remote: r, remotePath });
99
+ }
100
+ }
101
+ }
102
+
103
+ return { toAdd, toUpdate };
104
+ }
105
+
106
+ /**
107
+ * Ermittelt zu löschende Dateien (remote-only).
108
+ *
109
+ * remote: Map<rel, { rel, remotePath }>
110
+ */
111
+ export function computeRemoteDeletes({ local, remote }) {
112
+ const toDelete = [];
113
+ const localKeys = new Set(local.keys());
114
+
115
+ for (const [rel, r] of remote.entries()) {
116
+ if (!localKeys.has(rel)) {
117
+ toDelete.push({ rel, remotePath: r.remotePath });
118
+ }
119
+ }
120
+
121
+ return toDelete;
122
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * directory.mjs
3
+ *
4
+ * @author Carsten Nichte, 2025 / https://carsten-nichte.de/
5
+ *
6
+ */
7
+ // src/helpers/directory.mjs
8
+ import path from "path";
9
+
10
+ /**
11
+ * Konvertiert einen Pfad in POSIX-Notation (immer /)
12
+ */
13
+ export function toPosix(p) {
14
+ return p.split(path.sep).join("/");
15
+ }
16
+
17
+ /**
18
+ * Kürzt einen Pfad für die Progressanzeige:
19
+ * …/parent/file.ext
20
+ */
21
+ export function shortenPathForProgress(rel) {
22
+ if (!rel) return "";
23
+ const parts = rel.split("/");
24
+ if (parts.length <= 2) return rel;
25
+
26
+ const last = parts[parts.length - 1];
27
+ const prev = parts[parts.length - 2];
28
+ return `…/${prev}/${last}`;
29
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * hashing.mjs
3
+ *
4
+ * @author Carsten Nichte, 2025 / https://carsten-nichte.de/
5
+ *
6
+ */
7
+ // src/helpers/hashing.mjs
8
+ import fs from "fs";
9
+ import fsp from "fs/promises";
10
+ import { createHash } from "crypto";
11
+ import { Writable } from "stream";
12
+
13
+ /**
14
+ * Streaming-SHA256 für lokale Datei
15
+ */
16
+ export function hashLocalFile(filePath) {
17
+ return new Promise((resolve, reject) => {
18
+ const hash = createHash("sha256");
19
+ const stream = fs.createReadStream(filePath);
20
+ stream.on("error", reject);
21
+ stream.on("data", (chunk) => hash.update(chunk));
22
+ stream.on("end", () => resolve(hash.digest("hex")));
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Streaming-SHA256 für Remote-Datei via ssh2-sftp-client
28
+ */
29
+ export async function hashRemoteFile(sftp, remotePath) {
30
+ const hash = createHash("sha256");
31
+
32
+ const writable = new Writable({
33
+ write(chunk, enc, cb) {
34
+ hash.update(chunk);
35
+ cb();
36
+ },
37
+ });
38
+
39
+ await sftp.get(remotePath, writable);
40
+ return hash.digest("hex");
41
+ }
42
+
43
+ /**
44
+ * Kleiner Helper, der Hash-Cache + Persistenz kapselt.
45
+ *
46
+ * Erwartetes Cache-Format:
47
+ * {
48
+ * version: 1,
49
+ * local: { "<ns>:<rel>": { size, mtimeMs, hash } },
50
+ * remote: { "<ns>:<rel>": { size, modifyTime, hash } }
51
+ * }
52
+ */
53
+ export function createHashCache({
54
+ cachePath,
55
+ namespace,
56
+ flushInterval = 50,
57
+ }) {
58
+ const ns = namespace || "default";
59
+
60
+ let cache = {
61
+ version: 1,
62
+ local: {},
63
+ remote: {},
64
+ };
65
+
66
+ // Versuch: bestehenden Cache laden
67
+ (async () => {
68
+ try {
69
+ const raw = await fsp.readFile(cachePath, "utf8");
70
+ const parsed = JSON.parse(raw);
71
+ cache.version = parsed.version ?? 1;
72
+ cache.local = parsed.local ?? {};
73
+ cache.remote = parsed.remote ?? {};
74
+ } catch {
75
+ // kein Cache oder defekt → einfach neu anfangen
76
+ }
77
+ })().catch(() => {});
78
+
79
+ let dirty = false;
80
+ let dirtyCount = 0;
81
+
82
+ function cacheKey(relPath) {
83
+ return `${ns}:${relPath}`;
84
+ }
85
+
86
+ async function save(force = false) {
87
+ if (!dirty && !force) return;
88
+ const data = JSON.stringify(cache, null, 2);
89
+ await fsp.writeFile(cachePath, data, "utf8");
90
+ dirty = false;
91
+ dirtyCount = 0;
92
+ }
93
+
94
+ async function markDirty() {
95
+ dirty = true;
96
+ dirtyCount += 1;
97
+ if (dirtyCount >= flushInterval) {
98
+ await save();
99
+ }
100
+ }
101
+
102
+ async function getLocalHash(rel, meta) {
103
+ const key = cacheKey(rel);
104
+ const cached = cache.local[key];
105
+
106
+ if (
107
+ cached &&
108
+ cached.size === meta.size &&
109
+ cached.mtimeMs === meta.mtimeMs &&
110
+ cached.hash
111
+ ) {
112
+ return cached.hash;
113
+ }
114
+
115
+ const hash = await hashLocalFile(meta.localPath);
116
+ cache.local[key] = {
117
+ size: meta.size,
118
+ mtimeMs: meta.mtimeMs,
119
+ hash,
120
+ };
121
+ await markDirty();
122
+ return hash;
123
+ }
124
+
125
+ async function getRemoteHash(rel, meta, sftp) {
126
+ const key = cacheKey(rel);
127
+ const cached = cache.remote[key];
128
+
129
+ if (
130
+ cached &&
131
+ cached.size === meta.size &&
132
+ cached.modifyTime === meta.modifyTime &&
133
+ cached.hash
134
+ ) {
135
+ return cached.hash;
136
+ }
137
+
138
+ const hash = await hashRemoteFile(sftp, meta.remotePath);
139
+ cache.remote[key] = {
140
+ size: meta.size,
141
+ modifyTime: meta.modifyTime,
142
+ hash,
143
+ };
144
+ await markDirty();
145
+ return hash;
146
+ }
147
+
148
+ return {
149
+ cache,
150
+ cacheKey,
151
+ getLocalHash,
152
+ getRemoteHash,
153
+ save,
154
+ };
155
+ }
156
+
157
+ export async function getLocalHash(rel, meta, cacheLocal, key, markDirty) {
158
+ const cached = cacheLocal[key];
159
+ if (
160
+ cached &&
161
+ cached.size === meta.size &&
162
+ cached.mtimeMs === meta.mtimeMs &&
163
+ cached.hash
164
+ ) {
165
+ return cached.hash;
166
+ }
167
+
168
+ const hash = await hashLocalFile(meta.localPath);
169
+ cacheLocal[key] = {
170
+ size: meta.size,
171
+ mtimeMs: meta.mtimeMs,
172
+ hash,
173
+ };
174
+ if (markDirty) {
175
+ await markDirty();
176
+ }
177
+ return hash;
178
+ }
179
+
180
+ export async function getRemoteHash(rel, meta, cacheRemote, key, markDirty, sftp) {
181
+ const cached = cacheRemote[key];
182
+ if (
183
+ cached &&
184
+ cached.size === meta.size &&
185
+ cached.modifyTime === meta.modifyTime &&
186
+ cached.hash
187
+ ) {
188
+ return cached.hash;
189
+ }
190
+
191
+ const hash = await hashRemoteFile(sftp, meta.remotePath);
192
+ cacheRemote[key] = {
193
+ size: meta.size,
194
+ modifyTime: meta.modifyTime,
195
+ hash,
196
+ };
197
+ if (markDirty) {
198
+ await markDirty();
199
+ }
200
+ return hash;
201
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * progress-constants.mjs
3
+ * Central constants for progress and output formatting.
4
+ *
5
+ * @author Carsten Nichte, 2025 / https://carsten-nichte.de/
6
+ *
7
+ */
8
+ // src/helpers/progress-constants.mjs
9
+ export const hr1 = () => "─".repeat(65); // horizontal line -
10
+ export const hr2 = () => "=".repeat(65); // horizontal line =
11
+
12
+ // Einrückungen (Tabs) für konsistente Ausgabe
13
+ export const TAB_A = " "; // 3 Spaces
14
+ export const TAB_B = " "; // 6 Spaces
15
+
16
+ // Spinner-Frames für Progress-Anzeigen
17
+ export const SPINNER_FRAMES = [
18
+ "⠋",
19
+ "⠙",
20
+ "⠹",
21
+ "⠸",
22
+ "⠼",
23
+ "⠴",
24
+ "⠦",
25
+ "⠧",
26
+ "⠇",
27
+ "⠏",
28
+ ];
@@ -0,0 +1,185 @@
1
+ /**
2
+ * sidecar.mjs
3
+ *
4
+ * @author Carsten Nichte, 2025 / https://carsten-nichte.de/
5
+ *
6
+ */
7
+ // src/helpers/sidecar.mjs
8
+ import fs from "fs";
9
+ import fsp from "fs/promises";
10
+ import path from "path";
11
+ import { walkLocalPlain, walkRemotePlain } from "./walkers.mjs";
12
+
13
+ /** minimales Pattern-Matching à la minimatch (einfach: exakte Strings oder simple "*" am Ende) */
14
+ function matchesAny(patterns, relPath) {
15
+ if (!patterns || patterns.length === 0) return false;
16
+ return patterns.some((pattern) => {
17
+ if (!pattern) return false;
18
+ if (pattern === relPath) return true;
19
+ // primitive *-Unterstützung, falls du willst → sonst weglassen
20
+ if (pattern.endsWith("*")) {
21
+ const prefix = pattern.slice(0, -1);
22
+ return relPath.startsWith(prefix);
23
+ }
24
+ return false;
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Upload-Targets für Sidecar sammeln
30
+ */
31
+ export async function collectUploadTargets({
32
+ sidecarLocalRoot,
33
+ sidecarRemoteRoot,
34
+ uploadList,
35
+ }) {
36
+ const all = await walkLocalPlain(sidecarLocalRoot);
37
+ const results = [];
38
+
39
+ for (const [rel, meta] of all.entries()) {
40
+ if (matchesAny(uploadList, rel)) {
41
+ const remotePath = path.posix.join(sidecarRemoteRoot, rel);
42
+ results.push({
43
+ rel,
44
+ localPath: meta.localPath,
45
+ remotePath,
46
+ });
47
+ }
48
+ }
49
+
50
+ return results;
51
+ }
52
+
53
+ /**
54
+ * Download-Targets für Sidecar sammeln
55
+ */
56
+ export async function collectDownloadTargets({
57
+ sftp,
58
+ sidecarLocalRoot,
59
+ sidecarRemoteRoot,
60
+ downloadList,
61
+ }) {
62
+ const all = await walkRemotePlain(sftp, sidecarRemoteRoot);
63
+ const results = [];
64
+
65
+ for (const [rel, meta] of all.entries()) {
66
+ if (matchesAny(downloadList, rel)) {
67
+ const localPath = path.join(sidecarLocalRoot, rel);
68
+ results.push({
69
+ rel,
70
+ remotePath: meta.remotePath,
71
+ localPath,
72
+ });
73
+ }
74
+ }
75
+
76
+ return results;
77
+ }
78
+
79
+ /**
80
+ * Führt den Bypass-Modus aus (sidecar-upload / sidecar-download)
81
+ *
82
+ * Erwartete Parameter:
83
+ * - sftp: ssh2-sftp-client
84
+ * - connection: { sidecarLocalRoot, sidecarRemoteRoot, workers }
85
+ * - uploadList, downloadList: String-Arrays
86
+ * - options: { dryRun, runUploadList, runDownloadList }
87
+ * - runTasks: Workerpool-Funktion (items, workerCount, handler, label)
88
+ * - log, vlog, elog: Logging-Funktionen
89
+ * - symbols: { ADD, CHA, tab_a } → damit du deine bestehenden Symbole weiter nutzen kannst
90
+ */
91
+ export async function performBypassOnly({
92
+ sftp,
93
+ connection,
94
+ uploadList,
95
+ downloadList,
96
+ options,
97
+ runTasks,
98
+ log,
99
+ vlog,
100
+ elog,
101
+ symbols,
102
+ }) {
103
+ const { dryRun, runUploadList, runDownloadList } = options;
104
+ const { sidecarLocalRoot, sidecarRemoteRoot, workers } = connection;
105
+ const { ADD, CHA, tab_a } = symbols;
106
+
107
+ log("");
108
+ log("🚀 Bypass-Only Mode (skip-sync)");
109
+ log(`${tab_a}Sidecar Local: ${sidecarLocalRoot}`);
110
+ log(`${tab_a}Sidecar Remote: ${sidecarRemoteRoot}`);
111
+
112
+ if (runUploadList && !fs.existsSync(sidecarLocalRoot)) {
113
+ const msg = `Sidecar local root does not exist: ${sidecarLocalRoot}`;
114
+ elog(`❌ ${msg}`);
115
+ throw new Error(msg);
116
+ }
117
+
118
+ // Upload-Bereich
119
+ if (runUploadList) {
120
+ log("");
121
+ log("⬆️ Upload-Bypass (sidecar-upload) …");
122
+ const targets = await collectUploadTargets({
123
+ sidecarLocalRoot,
124
+ sidecarRemoteRoot,
125
+ uploadList,
126
+ });
127
+ log(`${tab_a}→ ${targets.length} files from uploadList`);
128
+
129
+ if (!dryRun) {
130
+ await runTasks(
131
+ targets,
132
+ workers,
133
+ async ({ localPath, remotePath, rel }) => {
134
+ const remoteDir = path.posix.dirname(remotePath);
135
+ try {
136
+ await sftp.mkdir(remoteDir, true);
137
+ } catch {
138
+ // Directory may already exist
139
+ }
140
+ await sftp.put(localPath, remotePath);
141
+ vlog && vlog(`${tab_a}${ADD} Uploaded (bypass): ${rel}`);
142
+ },
143
+ "Bypass Uploads"
144
+ );
145
+ } else {
146
+ for (const t of targets) {
147
+ log(`${tab_a}${ADD} (DRY-RUN) Upload: ${t.rel}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ // Download-Bereich
153
+ if (runDownloadList) {
154
+ log("");
155
+ log("⬇️ Download-Bypass (sidecar-download) …");
156
+ const targets = await collectDownloadTargets({
157
+ sftp,
158
+ sidecarLocalRoot,
159
+ sidecarRemoteRoot,
160
+ downloadList,
161
+ });
162
+ log(`${tab_a}→ ${targets.length} files from downloadList`);
163
+
164
+ if (!dryRun) {
165
+ await runTasks(
166
+ targets,
167
+ workers,
168
+ async ({ remotePath, localPath, rel }) => {
169
+ const localDir = path.dirname(localPath);
170
+ await fsp.mkdir(localDir, { recursive: true });
171
+ await sftp.get(remotePath, localPath);
172
+ vlog && vlog(`${tab_a}${CHA} Downloaded (bypass): ${rel}`);
173
+ },
174
+ "Bypass Downloads"
175
+ );
176
+ } else {
177
+ for (const t of targets) {
178
+ log(`${tab_a}${CHA} (DRY-RUN) Download: ${t.rel}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ log("");
184
+ log("✅ Bypass-only run finished.");
185
+ }