@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/servers.js CHANGED
@@ -1,49 +1,35 @@
1
- import fs from 'fs/promises';
2
1
  import path from 'path';
3
2
  import config from './config.js';
3
+ import jsonStore from './atomicJsonStore.js';
4
+
4
5
  const SERVERS_FILE_PATH = path.join(config.dataDir, 'servers.json');
6
+ const EMPTY = {};
5
7
 
6
8
  async function readServers() {
7
- try {
8
- await fs.access(SERVERS_FILE_PATH);
9
- const data = await fs.readFile(SERVERS_FILE_PATH, 'utf-8');
10
- return JSON.parse(data);
11
- } catch (error) {
12
- // Si le fichier n'existe pas, on retourne un objet vide
13
- return {};
14
- }
9
+ return jsonStore.readJson(SERVERS_FILE_PATH, EMPTY);
15
10
  }
16
11
 
17
- async function writeServers(servers) {
18
- await fs.writeFile(SERVERS_FILE_PATH, JSON.stringify(servers, null, 2));
19
- }
20
-
21
- async function addServer(alias, config) {
22
- const servers = await readServers();
23
- if (servers[alias]) {
24
- // L'alias existe, on le met à jour
25
- servers[alias] = { ...servers[alias], ...config };
26
- await writeServers(servers);
27
- return { success: true, message: `Serveur '${alias}' mis à jour avec succès.` };
28
- }
29
- // L'alias n'existe pas, on le crée
30
- servers[alias] = config;
31
- await writeServers(servers);
32
- return { success: true, message: `Serveur '${alias}' ajouté avec succès.` };
12
+ async function addServer(alias, serverConfig) {
13
+ let existed = false;
14
+ await jsonStore.updateJson(SERVERS_FILE_PATH, EMPTY, (servers) => {
15
+ existed = !!servers[alias];
16
+ servers[alias] = existed ? { ...servers[alias], ...serverConfig } : serverConfig;
17
+ return servers;
18
+ });
19
+ return { success: true, message: `Serveur '${alias}' ${existed ? 'mis à jour' : 'ajouté'} avec succès.` };
33
20
  }
34
21
 
35
22
  async function removeServer(alias) {
36
- const servers = await readServers();
37
- if (!servers[alias]) {
38
- throw new Error(`L'alias '${alias}' n'existe pas.`);
39
- }
40
- delete servers[alias];
41
- await writeServers(servers);
23
+ await jsonStore.updateJson(SERVERS_FILE_PATH, EMPTY, (servers) => {
24
+ if (!servers[alias]) throw new Error(`L'alias '${alias}' n'existe pas.`);
25
+ delete servers[alias];
26
+ return servers;
27
+ });
42
28
  return { success: true, message: `Serveur '${alias}' supprimé.` };
43
29
  }
44
30
 
45
31
  async function listServers() {
46
- return await readServers();
32
+ return readServers();
47
33
  }
48
34
 
49
35
  async function getServer(alias) {
@@ -55,4 +41,15 @@ async function getServer(alias) {
55
41
  return serverConfig;
56
42
  }
57
43
 
58
- export default { addServer, removeServer, listServers, getServer };
44
+ /** true si l'alias est marqué readonly:true dans servers.json */
45
+ async function isAliasReadOnly(alias) {
46
+ if (!alias) return false;
47
+ try {
48
+ const sc = await getServer(alias);
49
+ return sc.readonly === true || sc.readOnly === true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export default { addServer, removeServer, listServers, getServer, isAliasReadOnly };
package/sftp.js CHANGED
@@ -4,7 +4,10 @@ import path from 'path';
4
4
  import { glob } from 'glob';
5
5
  import queue from './queue.js';
6
6
  import serverManager from './servers.js';
7
+ import sourceAdapter from './sourceAdapter.js';
8
+ import fileOps from './fileOps.js';
7
9
  import micromatch from 'micromatch';
10
+ import utils from './utils.js';
8
11
 
9
12
  // Fonction utilitaire pour créer un dossier parent si nécessaire
10
13
  async function ensureLocalDir(filePath) {
@@ -17,20 +20,31 @@ async function ensureLocalDir(filePath) {
17
20
  }
18
21
  }
19
22
 
23
+ function hasGlobPattern(str) {
24
+ return /[*?[\]]/.test(str);
25
+ }
26
+
20
27
  // Fonction pour créer un dossier distant si nécessaire
21
28
  async function ensureRemoteDir(sftp, filePath) {
22
- const dir = path.dirname(filePath);
29
+ const dir = path.posix.dirname(filePath); // Utiliser posix pour les chemins distants
30
+
31
+ if (dir === '/' || dir === '.') {
32
+ return; // Pas besoin de créer la racine
33
+ }
34
+
23
35
  try {
24
36
  const exists = await sftp.exists(dir);
25
37
  if (!exists) {
26
- await sftp.mkdir(dir, true);
38
+ await sftp.mkdir(dir, true); // true pour créer récursivement
27
39
  queue.log('info', `Dossier distant créé: ${dir}`);
28
40
  }
29
41
  } catch (err) {
30
- // Si le dossier existe déjà, on ignore l'erreur
31
- if (!err.message.includes('already exists')) {
32
- throw err;
42
+ // Vérifier si c'est vraiment une erreur ou si le dossier existe déjà
43
+ const exists = await sftp.exists(dir).catch(() => false);
44
+ if (!exists) {
45
+ throw new Error(`Impossible de créer le dossier distant ${dir}: ${err.message}`);
33
46
  }
47
+ // Sinon, le dossier existe, on continue
34
48
  }
35
49
  }
36
50
 
@@ -58,16 +72,95 @@ async function expandFileList(pattern, basePath = '') {
58
72
 
59
73
  // Si c'est un pattern glob
60
74
  if (pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) {
61
- const fullPattern = path.join(normalizedBasePath, pattern);
75
+ const fullPattern = path.resolve(normalizedBasePath, pattern);
76
+ sourceAdapter.assertLocalPathAllowed(fullPattern);
62
77
  const files = await glob(fullPattern, { nodir: false });
78
+ for (const f of files) sourceAdapter.assertLocalPathAllowed(f);
63
79
  return files;
64
80
  }
65
81
 
66
82
  // Sinon c'est un fichier/dossier simple
67
- const fullPath = path.join(normalizedBasePath, pattern);
83
+ const fullPath = path.resolve(normalizedBasePath, pattern);
84
+ sourceAdapter.assertLocalPathAllowed(fullPath);
68
85
  return [fullPath];
69
86
  }
70
87
 
88
+ /**
89
+ * Copie une entrée remote -> remote via le pool SFTP partagé.
90
+ *
91
+ * - fichier : respecte force:false et append le basename si la cible est un dossier ;
92
+ * - dossier : copie récursivement les fichiers sous la racine cible ; force:false
93
+ * refuse une cible déjà existante afin d'éviter les merges/écrasements implicites.
94
+ *
95
+ * Note : les symlinks inclus dans un arbre sont volontairement ignorés par
96
+ * listFilesRecursive pour l'instant. La future stratégie direct/rsync devra les
97
+ * préserver ; le résultat expose un warning pour ne pas promettre une copie bit-à-bit.
98
+ */
99
+ async function transferServerToServerEntry(file, job, force) {
100
+ const srcAlias = file.source_alias || job.source_alias;
101
+ const srcPath = file.source_path || file.local || file.remote;
102
+ const tgtAlias = job.alias;
103
+ const tgtPath = file.remote;
104
+
105
+ if (!srcAlias) throw new Error("source_alias requis pour server_to_server.");
106
+ if (!srcPath) throw new Error("Chemin source requis (local/source_path) pour server_to_server.");
107
+ if (!tgtPath) throw new Error("Chemin destination remote requis pour server_to_server.");
108
+
109
+ const srcKind = await sourceAdapter.exists({ type: 'remote', alias: srcAlias, path: srcPath });
110
+ if (!srcKind) throw new Error(`Source introuvable: ${srcAlias}:${srcPath}`);
111
+
112
+ if (srcKind === 'd') {
113
+ const dstKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: tgtPath });
114
+ if (dstKind && dstKind !== 'd') {
115
+ throw new Error(`La destination ${tgtAlias}:${tgtPath} existe et n'est pas un dossier.`);
116
+ }
117
+ if (dstKind && !force) {
118
+ throw new Error(
119
+ `Le dossier destination ${tgtAlias}:${tgtPath} existe déjà. ` +
120
+ `Utilisez force:true pour fusionner/écraser explicitement.`
121
+ );
122
+ }
123
+
124
+ const rels = await sourceAdapter.listFilesRecursive(
125
+ { type: 'remote', alias: srcAlias, path: srcPath },
126
+ { recursive: true, ignorePatterns: [] }
127
+ );
128
+ let success = 0;
129
+ const failures = [];
130
+ for (const rel of rels) {
131
+ const from = path.posix.join(srcPath, rel);
132
+ const to = path.posix.join(tgtPath, rel);
133
+ try {
134
+ const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: from });
135
+ await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: to }, buf.content, { createDirs: true });
136
+ success++;
137
+ } catch (e) {
138
+ failures.push({ file: from, target: to, error: e.message });
139
+ }
140
+ }
141
+ return {
142
+ total: rels.length, success, failures,
143
+ warning: 'Copie dossier SFTP: symlinks et dossiers vides non préservés; utiliser une future stratégie rsync/tar pour une réplication bit-à-bit.'
144
+ };
145
+ }
146
+
147
+ let finalTarget = tgtPath;
148
+ const dstKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: tgtPath });
149
+ if (dstKind === 'd') {
150
+ finalTarget = path.posix.join(tgtPath, path.posix.basename(srcPath));
151
+ const nestedKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: finalTarget });
152
+ if (nestedKind && !force) {
153
+ throw new Error(`Le fichier distant ${tgtAlias}:${finalTarget} existe déjà. Utilisez force:true.`);
154
+ }
155
+ } else if (dstKind && !force) {
156
+ throw new Error(`Le fichier distant ${tgtAlias}:${tgtPath} existe déjà. Utilisez force:true.`);
157
+ }
158
+
159
+ const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: srcPath });
160
+ await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: finalTarget }, buf.content, { createDirs: true });
161
+ return { total: 1, success: 1, failures: [] };
162
+ }
163
+
71
164
  // Fonction principale de transfert avec support multi-fichiers
72
165
  async function executeTransfer(jobId) {
73
166
  const job = queue.getJob(jobId);
@@ -75,68 +168,101 @@ async function executeTransfer(jobId) {
75
168
 
76
169
  let sftp = null;
77
170
  try {
78
- const serverConfig = await serverManager.getServer(job.alias);
79
171
  queue.updateJobStatus(jobId, 'running');
80
172
 
81
- sftp = new SftpClient();
82
-
83
- // Configuration de la connexion
84
- const config = {
85
- host: serverConfig.host,
86
- port: 22,
87
- username: serverConfig.user,
88
- readyTimeout: 20000,
89
- retries: 3,
90
- retry_factor: 2,
91
- retry_minTimeout: 2000
92
- };
93
-
94
- if (serverConfig.keyPath) {
95
- config.privateKey = await fs.readFile(serverConfig.keyPath);
96
- } else if (serverConfig.password) {
97
- config.password = serverConfig.password;
98
- } else {
99
- throw new Error(`Aucune méthode d'authentification pour '${job.alias}'.`);
100
- }
101
-
102
- await sftp.connect(config);
103
-
104
173
  // Déterminer si on traite plusieurs fichiers
105
174
  const files = job.files || [{ local: job.local, remote: job.remote }];
106
175
  const isMultiple = Array.isArray(job.files) && job.files.length > 1;
107
-
176
+ const force = job.force === true;
177
+
108
178
  let successCount = 0;
109
179
  let failedFiles = [];
110
180
  let totalFiles = 0;
111
-
112
- // Traiter chaque fichier
113
- for (let i = 0; i < files.length; i++) {
114
- const file = files[i];
115
- const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
116
-
117
- try {
118
- if (job.direction === 'upload') {
119
- const localFiles = await expandFileList(file.local);
120
- totalFiles += localFiles.length;
121
- for (const localFile of localFiles) {
122
- queue.log('info', `Transfert${progress}: ${localFile}`);
123
- await handleUpload(sftp, localFile, file.remote);
124
- successCount++;
181
+
182
+ // server_to_server : 100% pool via sourceAdapter (fichiers + dossiers)
183
+ if (job.direction === 'server_to_server') {
184
+ const warnings = [];
185
+ for (let i = 0; i < files.length; i++) {
186
+ const file = files[i];
187
+ const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
188
+ const srcAlias = file.source_alias || job.source_alias;
189
+ const srcPath = file.source_path || file.local || file.remote;
190
+ try {
191
+ queue.log('info', `Transfert server_to_server${progress}: ${srcAlias}:${srcPath} → ${job.alias}:${file.remote}`);
192
+ const result = await transferServerToServerEntry(file, job, force);
193
+ totalFiles += result.total;
194
+ successCount += result.success;
195
+ if (result.failures?.length) failedFiles.push(...result.failures);
196
+ if (result.warning && !warnings.includes(result.warning)) warnings.push(result.warning);
197
+ } catch (err) {
198
+ totalFiles += 1; // l'item lui-même a été tenté, même si son contenu n'a pas pu être énuméré
199
+ queue.log('error', `Échec transfert ${srcPath || file.remote}: ${err.message}`);
200
+ failedFiles.push({ file: srcPath || file.remote, error: err.message });
201
+ }
202
+ }
203
+ job.transferWarnings = warnings;
204
+ } else {
205
+ // upload/download : SftpClient (dirs/glob) avec port configurable
206
+ const serverConfig = await serverManager.getServer(job.alias);
207
+ sftp = new SftpClient();
208
+ const sftpConfig = {
209
+ host: serverConfig.host,
210
+ port: utils.resolveSshPort(serverConfig),
211
+ username: serverConfig.user,
212
+ readyTimeout: 20000,
213
+ retries: 3,
214
+ retry_factor: 2,
215
+ retry_minTimeout: 2000
216
+ };
217
+ if (serverConfig.keyPath) {
218
+ sftpConfig.privateKey = await fs.readFile(serverConfig.keyPath);
219
+ } else if (serverConfig.password) {
220
+ sftpConfig.password = serverConfig.password;
221
+ } else {
222
+ throw new Error(`Aucune méthode d'authentification pour '${job.alias}'.`);
223
+ }
224
+ await sftp.connect(sftpConfig);
225
+
226
+ for (let i = 0; i < files.length; i++) {
227
+ const file = files[i];
228
+ const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
229
+
230
+ try {
231
+ if (job.direction === 'upload') {
232
+ const localFiles = await expandFileList(file.local);
233
+ totalFiles += localFiles.length;
234
+ for (const localFile of localFiles) {
235
+ queue.log('info', `Transfert${progress}: ${localFile}`);
236
+ // Fichier simple (pas dir) → pool sourceAdapter quand possible
237
+ let isDir = false;
238
+ try {
239
+ const st = await fs.stat(localFile);
240
+ isDir = st.isDirectory();
241
+ } catch { /* handled in handleUpload */ }
242
+ if (!isDir && !hasGlobPattern(file.remote || '')) {
243
+ const content = await fs.readFile(localFile);
244
+ let remotePath = file.remote;
245
+ // Si remote est un dossier existant, append basename — handleUpload fait ça ;
246
+ // pour pool on délègue à handleUpload si exists dir, sinon writeFile
247
+ await handleUpload(sftp, localFile, file.remote, force);
248
+ } else {
249
+ await handleUpload(sftp, localFile, file.remote, force);
250
+ }
251
+ successCount++;
252
+ }
253
+ } else if (job.direction === 'download') {
254
+ const downloadedCount = await handleDownload(sftp, file.remote, file.local, force);
255
+ totalFiles += downloadedCount;
256
+ successCount += downloadedCount;
125
257
  }
126
- } else if (job.direction === 'download') {
127
- const downloadedCount = await handleDownload(sftp, file.remote, file.local);
128
- totalFiles += downloadedCount;
129
- successCount += downloadedCount;
258
+ } catch (err) {
259
+ queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
260
+ failedFiles.push({ file: file.local || file.remote, error: err.message });
130
261
  }
131
- } catch (err) {
132
- queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
133
- failedFiles.push({ file: file.local || file.remote, error: err.message });
134
262
  }
135
263
  }
136
264
 
137
- await sftp.end();
138
-
139
- // Génération du rapport
265
+ // Génération du rapport (end() uniquement dans finally pour éviter double close)
140
266
  let status = successCount === totalFiles ? 'completed' : 'partial';
141
267
  let output = `Transfert ${job.direction}: ${successCount}/${totalFiles} fichiers réussis`;
142
268
 
@@ -145,7 +271,10 @@ async function executeTransfer(jobId) {
145
271
  if (successCount === 0) status = 'failed';
146
272
  }
147
273
 
148
- queue.updateJobStatus(jobId, status, { output, failedFiles });
274
+ if (job.transferWarnings?.length) {
275
+ output += `\nAvertissements: ${job.transferWarnings.join(' | ')}`;
276
+ }
277
+ queue.updateJobStatus(jobId, status, { output, failedFiles, warnings: job.transferWarnings || [] });
149
278
 
150
279
  } catch (err) {
151
280
  queue.updateJobStatus(jobId, 'failed', { error: err.message });
@@ -161,31 +290,65 @@ async function executeTransfer(jobId) {
161
290
  }
162
291
 
163
292
  // Gestion spécifique de l'upload
164
- async function handleUpload(sftp, localPath, remotePath) {
293
+ async function handleUpload(sftp, localPath, remotePath, force = false) {
294
+ sourceAdapter.assertLocalPathAllowed(localPath);
295
+ let localStats;
165
296
  try {
166
- const stats = await fs.stat(localPath);
167
-
168
- if (stats.isDirectory()) {
169
- // Upload d'un dossier entier
170
- await sftp.uploadDir(localPath, remotePath);
171
- } else {
172
- // Upload d'un fichier - créer le dossier parent si nécessaire
173
- await ensureRemoteDir(sftp, remotePath);
174
- await sftp.put(localPath, remotePath);
175
- }
297
+ localStats = await fs.stat(localPath);
176
298
  } catch (err) {
177
- // Si le fichier local n'existe pas
178
299
  if (err.code === 'ENOENT') {
179
300
  throw new Error(`Fichier local introuvable: ${localPath}`);
180
301
  }
181
302
  throw err;
182
303
  }
304
+
305
+ if (localStats.isDirectory()) {
306
+ const remoteExists = await sftp.exists(remotePath).catch(() => false);
307
+ if (remoteExists) {
308
+ try {
309
+ const remoteStats = await sftp.stat(remotePath);
310
+ if (!remoteStats.isDirectory) {
311
+ throw new Error(`Impossible d'envoyer un dossier local vers un fichier distant existant: ${remotePath}. Spécifiez un dossier de destination.`);
312
+ }
313
+ } catch (e) {
314
+ if (e.message && e.message.includes('Impossible d')) throw e;
315
+ }
316
+ }
317
+ await sftp.uploadDir(localPath, remotePath);
318
+ return;
319
+ }
320
+
321
+ let finalRemotePath;
322
+ const remoteExists = await sftp.exists(remotePath).catch(() => false);
323
+
324
+ if (remoteExists) {
325
+ let remoteStats;
326
+ try {
327
+ remoteStats = await sftp.stat(remotePath);
328
+ } catch (e) {
329
+ remoteStats = null;
330
+ }
331
+
332
+ if (remoteStats && remoteStats.isDirectory) {
333
+ finalRemotePath = path.posix.join(remotePath, path.basename(localPath));
334
+ } else {
335
+ if (!force) {
336
+ throw new Error(`Le fichier distant ${remotePath} existe déjà. Utilisez force:true pour l'écraser.`);
337
+ }
338
+ finalRemotePath = remotePath;
339
+ }
340
+ } else {
341
+ finalRemotePath = remotePath;
342
+ }
343
+
344
+ await ensureRemoteDir(sftp, finalRemotePath);
345
+ await sftp.put(localPath, finalRemotePath);
183
346
  }
184
347
 
185
348
  // Gestion spécifique du download
186
- async function handleDownload(sftp, remotePath, localPath) {
187
- // Si le chemin distant contient un glob
188
- if (micromatch.isMatch(remotePath, '**/*')) {
349
+ async function handleDownload(sftp, remotePath, localPath, force = false) {
350
+ sourceAdapter.assertLocalPathAllowed(localPath);
351
+ if (hasGlobPattern(remotePath)) {
189
352
  const parentDir = path.dirname(remotePath);
190
353
  const pattern = path.basename(remotePath);
191
354
 
@@ -197,20 +360,23 @@ async function handleDownload(sftp, remotePath, localPath) {
197
360
  throw new Error(`Aucun fichier distant ne correspond au pattern: ${remotePath}`);
198
361
  }
199
362
 
200
- // S'assurer que le dossier local existe
201
- await ensureLocalDir(localPath);
363
+ await fs.mkdir(localPath, { recursive: true });
202
364
 
203
- // Télécharger chaque fichier correspondant
204
365
  for (const fileName of matchingFiles) {
205
- const remoteFile = path.join(parentDir, fileName);
366
+ const remoteFile = path.posix.join(parentDir, fileName);
206
367
  const localFile = path.join(localPath, fileName);
368
+ sourceAdapter.assertLocalPathAllowed(localFile);
369
+
370
+ const localFileExists = await fs.access(localFile).then(() => true).catch(() => false);
371
+ if (localFileExists && !force) {
372
+ throw new Error(`Le fichier local ${localFile} existe déjà. Utilisez force:true pour l'écraser.`);
373
+ }
374
+
207
375
  queue.log('info', `Téléchargement (glob): ${remoteFile} -> ${localFile}`);
208
376
  await sftp.get(remoteFile, localFile);
209
377
  }
210
- // Retourner le nombre de fichiers téléchargés pour le rapport
211
378
  return matchingFiles.length;
212
379
  } catch (err) {
213
- // Si le dossier parent n'existe pas, on propage l'erreur
214
380
  if (err.code === 2) {
215
381
  throw new Error(`Le dossier parent pour le glob n'existe pas: ${parentDir}`);
216
382
  }
@@ -218,21 +384,53 @@ async function handleDownload(sftp, remotePath, localPath) {
218
384
  }
219
385
 
220
386
  } else {
221
- // Comportement normal pour un fichier ou un dossier unique
222
- const exists = await sftp.exists(remotePath);
223
- if (!exists) {
387
+ const remoteExists = await sftp.exists(remotePath);
388
+ if (!remoteExists) {
224
389
  throw new Error(`Fichier distant introuvable: ${remotePath}`);
225
390
  }
226
391
 
227
- const stats = await sftp.stat(remotePath);
228
- await ensureLocalDir(localPath);
392
+ const remoteStats = await sftp.stat(remotePath);
229
393
 
230
- if (stats.isDirectory) {
394
+ if (remoteStats.isDirectory) {
395
+ await fs.mkdir(localPath, { recursive: true });
231
396
  await sftp.downloadDir(remotePath, localPath);
397
+ return 1;
398
+ }
399
+
400
+ let finalLocalPath;
401
+ const localExists = await fs.access(localPath).then(() => true).catch(() => false);
402
+
403
+ if (localExists) {
404
+ let localStats;
405
+ try {
406
+ localStats = await fs.stat(localPath);
407
+ } catch (e) {
408
+ localStats = null;
409
+ }
410
+
411
+ if (localStats && localStats.isDirectory()) {
412
+ finalLocalPath = path.join(localPath, path.basename(remotePath));
413
+ } else {
414
+ if (!force) {
415
+ throw new Error(`Le fichier local ${localPath} existe déjà. Utilisez force:true pour l'écraser.`);
416
+ }
417
+ finalLocalPath = localPath;
418
+ }
232
419
  } else {
233
- await sftp.get(remotePath, localPath);
420
+ const hasExt = path.extname(localPath) !== '';
421
+ if (hasExt) {
422
+ finalLocalPath = localPath;
423
+ } else {
424
+ await fs.mkdir(localPath, { recursive: true });
425
+ finalLocalPath = path.join(localPath, path.basename(remotePath));
426
+ }
234
427
  }
235
- return 1; // Un seul item téléchargé
428
+
429
+ sourceAdapter.assertLocalPathAllowed(finalLocalPath);
430
+ const localDir = path.dirname(finalLocalPath);
431
+ await fs.mkdir(localDir, { recursive: true });
432
+ await sftp.get(remotePath, finalLocalPath);
433
+ return 1;
236
434
  }
237
435
  }
238
436