@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.8.0
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/.env.example +27 -15
- package/CHANGELOG.md +174 -0
- package/README.fr.md +356 -0
- package/README.md +314 -60
- package/apis.js +20 -64
- package/atomicJsonStore.js +117 -0
- package/compareEngine.js +132 -0
- package/config.js +40 -4
- package/diagnose.js +89 -0
- package/diffEngine.js +141 -0
- package/diffFormatter.js +114 -0
- package/fileOps.js +275 -0
- package/fleet.js +170 -0
- package/groups.js +25 -0
- package/guide.js +236 -0
- package/history.js +5 -44
- package/infraTopology.js +336 -0
- package/inventory.js +151 -0
- package/lhm.plugin.json +7 -0
- package/notes.js +3 -0
- package/package.json +11 -26
- package/policies.js +8 -0
- package/projects.js +108 -0
- package/queue.js +180 -125
- package/server.js +2552 -469
- package/servers.js +30 -33
- package/sftp.js +284 -86
- package/shellSessions.js +265 -0
- package/snapshotManager.js +320 -0
- package/sourceAdapter.js +410 -0
- package/ssh.js +184 -86
- package/sshPool.js +161 -148
- package/sshTrust.js +214 -0
- package/toolMetadata.js +76 -0
- package/tunnels.js +243 -0
- package/utils.js +215 -0
- package/workSession.js +139 -0
- package/publish1github.md +0 -216
- package/test_features.js +0 -112
package/sourceAdapter.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import fsSync from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import micromatch from 'micromatch';
|
|
6
|
+
import serverManager from './servers.js';
|
|
7
|
+
import sshPool from './sshPool.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import config from './config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* sourceAdapter — Abstraction unifiée local/remote.
|
|
13
|
+
*
|
|
14
|
+
* Une "source" est un objet : { type: 'local'|'remote', path, alias? }
|
|
15
|
+
* - type 'local' → utilise fs directement (ZÉRO SSH, sur le PC hôte du MCP)
|
|
16
|
+
* - type 'remote' → réutilise une connexion du POOL SSH (sshPool) et ouvre un
|
|
17
|
+
* canal SFTP dessus (pas de reconnexion TCP/handshake par op).
|
|
18
|
+
*
|
|
19
|
+
* v10.2.0 : le transport remote passe par le pool SSH partagé (perf : plus de
|
|
20
|
+
* reconnexion à chaque opération). Un wrapper promisifié reproduit fidèlement
|
|
21
|
+
* l'API de ssh2-sftp-client précédemment utilisée (get/put/stat/exists/list/mkdir),
|
|
22
|
+
* pour zéro régression sur les couches supérieures (fileOps, diffEngine, snapshots).
|
|
23
|
+
*
|
|
24
|
+
* Toutes les opérations fichiers de haut niveau s'appuient sur ce module.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Schéma Zod partagé pour une "source" (local ou remote). Défini ici pour
|
|
28
|
+
// cohabiter avec le traitement (sourceAdapter) et être importé par server.js.
|
|
29
|
+
const sourceSchema = z.object({
|
|
30
|
+
type: z.enum(['local', 'remote']).describe("'local' = PC hôte du MCP (sans SSH), 'remote' = serveur distant (SFTP)"),
|
|
31
|
+
path: z.string().describe("Chemin absolu du fichier"),
|
|
32
|
+
alias: z.string().optional().describe("Alias du serveur (requis si type='remote')"),
|
|
33
|
+
label: z.string().optional().describe("Nom lisible optionnel pour cette source (ex: 'nginx-prod'). Utile pour comparer des fichiers à des emplacements différents.")
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Enrobe le sous-système SFTP brut de ssh2 en une API Promise identique à
|
|
37
|
+
// celle de ssh2-sftp-client (sous-ensemble utilisé ici).
|
|
38
|
+
function promisifySftp(rawSftp) {
|
|
39
|
+
return {
|
|
40
|
+
// Lit un fichier → Buffer
|
|
41
|
+
get(remotePath) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
rawSftp.readFile(remotePath, (err, data) => err ? reject(err) : resolve(data));
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
// Écrit un Buffer
|
|
47
|
+
put(buffer, remotePath) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
rawSftp.writeFile(remotePath, buffer, (err) => err ? reject(err) : resolve());
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
// stat → { size, modifyTime(ms), accessTime(ms), isDirectory, isFile, mode }
|
|
53
|
+
stat(remotePath) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
rawSftp.stat(remotePath, (err, s) => {
|
|
56
|
+
if (err) return reject(err);
|
|
57
|
+
resolve({
|
|
58
|
+
size: s.size,
|
|
59
|
+
modifyTime: (s.mtime || 0) * 1000, // ssh2 = secondes → ms
|
|
60
|
+
accessTime: (s.atime || 0) * 1000,
|
|
61
|
+
isDirectory: s.isDirectory(),
|
|
62
|
+
isFile: s.isFile(),
|
|
63
|
+
mode: s.mode
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
// exists → false | 'd' | 'l' | '-'
|
|
69
|
+
exists(remotePath) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
rawSftp.lstat(remotePath, (err, s) => {
|
|
72
|
+
if (err) return resolve(false);
|
|
73
|
+
if (s.isDirectory()) return resolve('d');
|
|
74
|
+
if (typeof s.isSymbolicLink === 'function' && s.isSymbolicLink()) return resolve('l');
|
|
75
|
+
return resolve('-');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
// list → [{ name, type:'d'|'l'|'-' }]
|
|
80
|
+
list(remotePath) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
rawSftp.readdir(remotePath, (err, entries) => {
|
|
83
|
+
if (err) return reject(err);
|
|
84
|
+
resolve(entries.map(e => {
|
|
85
|
+
let type = '-';
|
|
86
|
+
const a = e.attrs;
|
|
87
|
+
if (a && typeof a.isDirectory === 'function') {
|
|
88
|
+
if (a.isDirectory()) type = 'd';
|
|
89
|
+
else if (typeof a.isSymbolicLink === 'function' && a.isSymbolicLink()) type = 'l';
|
|
90
|
+
} else if (e.longname && e.longname[0] === 'd') {
|
|
91
|
+
type = 'd';
|
|
92
|
+
} else if (e.longname && e.longname[0] === 'l') {
|
|
93
|
+
type = 'l';
|
|
94
|
+
}
|
|
95
|
+
return { name: e.filename, type };
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
// mkdir(dir, recursive) — recursive implémenté (ssh2 brut ne le fait pas)
|
|
101
|
+
async mkdir(dir, recursive) {
|
|
102
|
+
const makeOne = (p) => new Promise((resolve, reject) => {
|
|
103
|
+
rawSftp.mkdir(p, (err) => {
|
|
104
|
+
// ignore "existe déjà" (Failure générique de SFTP)
|
|
105
|
+
if (err && !/failure|exist/i.test(err.message || '')) return reject(err);
|
|
106
|
+
resolve();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
if (!recursive) return makeOne(dir);
|
|
110
|
+
const parts = dir.split('/').filter(Boolean);
|
|
111
|
+
let cur = dir.startsWith('/') ? '' : '.';
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
cur = cur === '' ? '/' + part : cur + '/' + part;
|
|
114
|
+
await makeOne(cur).catch(() => { /* niveau intermédiaire déjà présent */ });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Réutilise une connexion du pool SSH, ouvre un canal SFTP dessus, exécute fn,
|
|
121
|
+
// ferme le canal (PAS la connexion) et rend la connexion au pool.
|
|
122
|
+
async function withSftp(alias, fn) {
|
|
123
|
+
const serverConfig = await serverManager.getServer(alias);
|
|
124
|
+
const conn = await sshPool.getConnection(alias, serverConfig);
|
|
125
|
+
let rawSftp = null;
|
|
126
|
+
try {
|
|
127
|
+
rawSftp = await new Promise((resolve, reject) => {
|
|
128
|
+
conn.client.sftp((err, sftp) => err ? reject(err) : resolve(sftp));
|
|
129
|
+
});
|
|
130
|
+
return await fn(promisifySftp(rawSftp));
|
|
131
|
+
} finally {
|
|
132
|
+
try { if (rawSftp) rawSftp.end(); } catch (e) { /* canal déjà fermé */ }
|
|
133
|
+
sshPool.releaseConnection(conn.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Décrit une source de façon claire (évite que l'IA se perde entre serveurs)
|
|
138
|
+
// Retourne { server, type, path, label }
|
|
139
|
+
// server : 'localhost' (local) ou l'alias du serveur (remote)
|
|
140
|
+
// label : label personnalisé si fourni (source.label), sinon "server:path"
|
|
141
|
+
// Utile quand on compare des fichiers à des emplacements différents
|
|
142
|
+
// (ex: .bashrc sur un VPS vs .zshrc sur un autre → labels "shell-vps1"/"shell-vps2")
|
|
143
|
+
function describe(source) {
|
|
144
|
+
const server = source.type === 'local' ? 'localhost' : source.alias;
|
|
145
|
+
return {
|
|
146
|
+
server,
|
|
147
|
+
type: source.type,
|
|
148
|
+
path: source.path,
|
|
149
|
+
label: source.label || `${server}:${source.path}`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Politique transversale des chemins locaux.
|
|
155
|
+
*
|
|
156
|
+
* Historiquement MCP_ALLOWED_ROOTS n'était vérifié que dans fileOps.js, ce qui
|
|
157
|
+
* laissait snapshots/transferts contourner la restriction. La validation vit
|
|
158
|
+
* désormais au niveau sourceAdapter, porte d'entrée commune des accès locaux.
|
|
159
|
+
*
|
|
160
|
+
* La vérification combine :
|
|
161
|
+
* - contrôle lexical du chemin absolu ;
|
|
162
|
+
* - realpath du chemin (ou du parent existant le plus proche) pour empêcher
|
|
163
|
+
* un symlink situé dans une racine autorisée de sortir de cette racine.
|
|
164
|
+
*/
|
|
165
|
+
function assertLocalPathAllowed(sourcePath) {
|
|
166
|
+
if (!config.allowedRoots || config.allowedRoots.length === 0) return;
|
|
167
|
+
if (!sourcePath || typeof sourcePath !== 'string') {
|
|
168
|
+
throw new Error("Chemin local invalide.");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const requested = path.resolve(sourcePath);
|
|
172
|
+
|
|
173
|
+
function realExistingAnchor(p) {
|
|
174
|
+
let cur = p;
|
|
175
|
+
while (!fsSync.existsSync(cur)) {
|
|
176
|
+
const parent = path.dirname(cur);
|
|
177
|
+
if (parent === cur) break;
|
|
178
|
+
cur = parent;
|
|
179
|
+
}
|
|
180
|
+
try { return fsSync.realpathSync.native(cur); } catch { return path.resolve(cur); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const requestedAnchorReal = realExistingAnchor(requested);
|
|
184
|
+
const allowed = config.allowedRoots.some((root) => {
|
|
185
|
+
const rootAbs = path.resolve(root);
|
|
186
|
+
const lexical = requested === rootAbs || requested.startsWith(rootAbs + path.sep);
|
|
187
|
+
if (!lexical) return false;
|
|
188
|
+
const rootReal = realExistingAnchor(rootAbs);
|
|
189
|
+
return requestedAnchorReal === rootReal || requestedAnchorReal.startsWith(rootReal + path.sep);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (!allowed) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Accès local refusé : '${requested}' est hors MCP_ALLOWED_ROOTS (${config.allowedRoots.join(', ')}).`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Valide la structure d'une source
|
|
200
|
+
function validateSource(source) {
|
|
201
|
+
if (!source || typeof source !== 'object') {
|
|
202
|
+
throw new Error("Source invalide : objet attendu { type, path, alias? }");
|
|
203
|
+
}
|
|
204
|
+
if (source.type !== 'local' && source.type !== 'remote') {
|
|
205
|
+
throw new Error(`Type de source invalide : '${source.type}'. Attendu 'local' ou 'remote'.`);
|
|
206
|
+
}
|
|
207
|
+
if (!source.path) {
|
|
208
|
+
throw new Error("Source invalide : 'path' est requis.");
|
|
209
|
+
}
|
|
210
|
+
if (source.type === 'remote' && !source.alias) {
|
|
211
|
+
throw new Error("Source remote invalide : 'alias' est requis quand type='remote'.");
|
|
212
|
+
}
|
|
213
|
+
if (source.type === 'local') {
|
|
214
|
+
assertLocalPathAllowed(source.path);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export { sourceSchema, assertLocalPathAllowed };
|
|
219
|
+
|
|
220
|
+
export default {
|
|
221
|
+
assertLocalPathAllowed,
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Décrit une source : { server, type, path, label }.
|
|
225
|
+
* server = 'localhost' ou alias serveur. Utile pour indiquer clairement
|
|
226
|
+
* l'origine d'un fichier lu (évite la confusion entre serveurs).
|
|
227
|
+
*/
|
|
228
|
+
describe,
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Lit un fichier. Retourne { content: Buffer, mtime, size }.
|
|
232
|
+
*/
|
|
233
|
+
async readFile(source) {
|
|
234
|
+
validateSource(source);
|
|
235
|
+
if (source.type === 'local') {
|
|
236
|
+
const content = await fs.readFile(source.path);
|
|
237
|
+
const stat = await fs.stat(source.path);
|
|
238
|
+
return { content, mtime: stat.mtimeMs, size: stat.size };
|
|
239
|
+
}
|
|
240
|
+
return withSftp(source.alias, async (sftp) => {
|
|
241
|
+
const content = await sftp.get(source.path); // Buffer
|
|
242
|
+
const stat = await sftp.stat(source.path);
|
|
243
|
+
return { content, mtime: stat.modifyTime, size: stat.size };
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Écrit un fichier. contentBuffer doit être un Buffer.
|
|
249
|
+
* options = { createDirs: bool (défaut true) }
|
|
250
|
+
* Retourne { size }.
|
|
251
|
+
*/
|
|
252
|
+
async writeFile(source, contentBuffer, options = {}) {
|
|
253
|
+
validateSource(source);
|
|
254
|
+
const createDirs = options.createDirs !== false;
|
|
255
|
+
|
|
256
|
+
if (source.type === 'local') {
|
|
257
|
+
if (createDirs) {
|
|
258
|
+
await fs.mkdir(path.dirname(source.path), { recursive: true });
|
|
259
|
+
}
|
|
260
|
+
await fs.writeFile(source.path, contentBuffer);
|
|
261
|
+
const stat = await fs.stat(source.path);
|
|
262
|
+
return { size: stat.size };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return withSftp(source.alias, async (sftp) => {
|
|
266
|
+
if (createDirs) {
|
|
267
|
+
const dir = path.posix.dirname(source.path);
|
|
268
|
+
if (dir && dir !== '/' && dir !== '.') {
|
|
269
|
+
const exists = await sftp.exists(dir);
|
|
270
|
+
if (!exists) await sftp.mkdir(dir, true);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await sftp.put(contentBuffer, source.path);
|
|
274
|
+
const stat = await sftp.stat(source.path);
|
|
275
|
+
return { size: stat.size };
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Retourne les stats d'un fichier/dossier (format brut fs ou sftp).
|
|
281
|
+
*/
|
|
282
|
+
async stat(source) {
|
|
283
|
+
validateSource(source);
|
|
284
|
+
if (source.type === 'local') {
|
|
285
|
+
return fs.stat(source.path);
|
|
286
|
+
}
|
|
287
|
+
return withSftp(source.alias, async (sftp) => sftp.stat(source.path));
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Vérifie l'existence d'un chemin. Retourne false, 'd', '-', 'l'.
|
|
292
|
+
*/
|
|
293
|
+
async exists(source) {
|
|
294
|
+
validateSource(source);
|
|
295
|
+
if (source.type === 'local') {
|
|
296
|
+
try {
|
|
297
|
+
const stat = await fs.stat(source.path);
|
|
298
|
+
return stat.isDirectory() ? 'd' : '-';
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return withSftp(source.alias, async (sftp) => sftp.exists(source.path));
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Liste le contenu d'un dossier. Retourne [{ name, type }].
|
|
308
|
+
* type: 'd' (dossier), '-' (fichier), 'l' (lien).
|
|
309
|
+
*/
|
|
310
|
+
async listDir(source) {
|
|
311
|
+
validateSource(source);
|
|
312
|
+
if (source.type === 'local') {
|
|
313
|
+
const entries = await fs.readdir(source.path, { withFileTypes: true });
|
|
314
|
+
return entries.map(e => ({
|
|
315
|
+
name: e.name,
|
|
316
|
+
type: e.isDirectory() ? 'd' : e.isSymbolicLink() ? 'l' : '-'
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
return withSftp(source.alias, async (sftp) => {
|
|
320
|
+
const list = await sftp.list(source.path);
|
|
321
|
+
return list.map(e => ({ name: e.name, type: e.type }));
|
|
322
|
+
});
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Liste récursivement tous les FICHIERS d'un dossier.
|
|
327
|
+
* Retourne un tableau de chemins RELATIFS (posix, ex: "conf/nginx.conf").
|
|
328
|
+
* options = { recursive: bool (défaut true), ignorePatterns: string[] }
|
|
329
|
+
* ignorePatterns : motifs glob (micromatch) testés contre chaque chemin relatif
|
|
330
|
+
* ET chaque segment (ex: 'node_modules' ignore tout le dossier).
|
|
331
|
+
*
|
|
332
|
+
* Pour remote : tout le parcours se fait dans UNE SEULE connexion SFTP.
|
|
333
|
+
*/
|
|
334
|
+
async listFilesRecursive(source, options = {}) {
|
|
335
|
+
validateSource(source);
|
|
336
|
+
const recursive = options.recursive !== false;
|
|
337
|
+
const ignore = options.ignorePatterns || [];
|
|
338
|
+
|
|
339
|
+
const shouldIgnore = (relPath) => {
|
|
340
|
+
if (ignore.length === 0) return false;
|
|
341
|
+
const segments = relPath.split('/');
|
|
342
|
+
// ignore si le chemin complet matche, ou si un segment matche
|
|
343
|
+
return micromatch.isMatch(relPath, ignore) ||
|
|
344
|
+
segments.some(seg => micromatch.isMatch(seg, ignore));
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
if (source.type === 'local') {
|
|
348
|
+
const results = [];
|
|
349
|
+
const walk = async (absDir, relDir) => {
|
|
350
|
+
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
351
|
+
for (const e of entries) {
|
|
352
|
+
const relPath = relDir ? `${relDir}/${e.name}` : e.name;
|
|
353
|
+
if (shouldIgnore(relPath)) continue;
|
|
354
|
+
if (e.isDirectory()) {
|
|
355
|
+
if (recursive) await walk(path.join(absDir, e.name), relPath);
|
|
356
|
+
} else if (e.isFile()) {
|
|
357
|
+
results.push(relPath);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
await walk(source.path, '');
|
|
362
|
+
return results.sort();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Remote : une seule connexion SFTP pour tout le walk
|
|
366
|
+
return withSftp(source.alias, async (sftp) => {
|
|
367
|
+
const results = [];
|
|
368
|
+
const walk = async (absDir, relDir) => {
|
|
369
|
+
const list = await sftp.list(absDir);
|
|
370
|
+
for (const e of list) {
|
|
371
|
+
const relPath = relDir ? `${relDir}/${e.name}` : e.name;
|
|
372
|
+
if (shouldIgnore(relPath)) continue;
|
|
373
|
+
if (e.type === 'd') {
|
|
374
|
+
if (recursive) await walk(path.posix.join(absDir, e.name), relPath);
|
|
375
|
+
} else if (e.type === '-') {
|
|
376
|
+
results.push(relPath);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
await walk(source.path, '');
|
|
381
|
+
return results.sort();
|
|
382
|
+
});
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Lit plusieurs fichiers d'un dossier remote dans UNE connexion SFTP.
|
|
387
|
+
* relPaths : chemins relatifs à basePath. Retourne Map<relPath, {content:Buffer, hash}>.
|
|
388
|
+
* Pour local, lit simplement via fs (pas de coût connexion).
|
|
389
|
+
*/
|
|
390
|
+
async readManyHashes(source, basePath, relPaths) {
|
|
391
|
+
validateSource(source);
|
|
392
|
+
const result = new Map();
|
|
393
|
+
|
|
394
|
+
if (source.type === 'local') {
|
|
395
|
+
for (const rel of relPaths) {
|
|
396
|
+
const buf = await fs.readFile(path.join(basePath, rel));
|
|
397
|
+
result.set(rel, { content: buf, hash: crypto.createHash('sha256').update(buf).digest('hex') });
|
|
398
|
+
}
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return withSftp(source.alias, async (sftp) => {
|
|
403
|
+
for (const rel of relPaths) {
|
|
404
|
+
const buf = await sftp.get(path.posix.join(basePath, rel));
|
|
405
|
+
result.set(rel, { content: buf, hash: crypto.createHash('sha256').update(buf).digest('hex') });
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
};
|