@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.3.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 +21 -15
- package/.gencodedoc.yaml +76 -0
- package/README.fr.md +369 -0
- package/README.md +333 -61
- package/ROADMAP.md +21 -0
- package/apis.js +17 -0
- package/compareEngine.js +132 -0
- package/config.js +35 -4
- package/diagnose.js +89 -0
- package/diffEngine.js +141 -0
- package/diffFormatter.js +114 -0
- package/fileOps.js +275 -0
- package/guide.js +236 -0
- package/history.js +8 -1
- package/notes.js +87 -0
- package/package.json +5 -22
- package/policies.js +76 -0
- package/queue.js +107 -121
- package/server.js +1460 -108
- package/sftp.js +131 -41
- package/shellSessions.js +237 -0
- package/snapshotManager.js +324 -0
- package/sourceAdapter.js +357 -0
- package/ssh.js +168 -84
- package/sshPool.js +52 -56
- package/test_mcp.js +55 -0
- package/tunnels.js +224 -0
- package/utils.js +6 -0
- package/publish1github.md +0 -216
package/ssh.js
CHANGED
|
@@ -4,6 +4,7 @@ import queue from './queue.js';
|
|
|
4
4
|
import serverManager from './servers.js';
|
|
5
5
|
import sshPool from './sshPool.js';
|
|
6
6
|
import config from './config.js';
|
|
7
|
+
import policies from './policies.js';
|
|
7
8
|
|
|
8
9
|
const STREAMING_COMMANDS = [
|
|
9
10
|
/^pm2\s+logs?\b/i,
|
|
@@ -25,32 +26,56 @@ function isStreamingCommand(cmd) {
|
|
|
25
26
|
|
|
26
27
|
// Configuration des réponses automatiques pour les prompts interactifs
|
|
27
28
|
const INTERACTIVE_RESPONSES = {
|
|
28
|
-
// Patterns courants et leurs réponses
|
|
29
29
|
'continue connecting': 'yes',
|
|
30
30
|
'Are you sure': 'yes',
|
|
31
|
-
'password:': null, // Sera géré séparément
|
|
32
|
-
'Password:': null,
|
|
33
31
|
'Do you want to continue': 'y',
|
|
32
|
+
'[y/n]': 'y',
|
|
33
|
+
'(yes/no)': 'yes',
|
|
34
|
+
'(y/N)': 'y',
|
|
35
|
+
'(Y/n)': 'y',
|
|
34
36
|
'Overwrite': 'y',
|
|
35
|
-
'Save': 'y'
|
|
37
|
+
'Save': 'y',
|
|
38
|
+
'accept': 'yes',
|
|
39
|
+
'confirm': 'yes',
|
|
40
|
+
'password:': 'PASSWORD_REQUIRED',
|
|
41
|
+
'Password:': 'PASSWORD_REQUIRED',
|
|
42
|
+
'sudo': 'PASSWORD_REQUIRED'
|
|
36
43
|
};
|
|
37
44
|
|
|
38
45
|
// Détecte si une sortie contient un prompt interactif
|
|
39
|
-
function detectInteractivePrompt(output) {
|
|
46
|
+
function detectInteractivePrompt(output, customResponses = {}) {
|
|
40
47
|
const lastLine = output.split('\n').pop().toLowerCase();
|
|
41
|
-
|
|
48
|
+
const lastLines = output.split('\n').slice(-5).join('\n').toLowerCase();
|
|
49
|
+
|
|
50
|
+
// 1. Check custom responses first (supporte regex)
|
|
51
|
+
for (const [pattern, response] of Object.entries(customResponses)) {
|
|
52
|
+
try {
|
|
53
|
+
const regex = new RegExp(pattern, 'i');
|
|
54
|
+
if (regex.test(lastLines)) {
|
|
55
|
+
return { pattern, response, needsInput: true };
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (lastLines.includes(pattern.toLowerCase())) {
|
|
59
|
+
return { pattern, response, needsInput: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Check built-in patterns
|
|
42
65
|
for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
|
|
43
|
-
if (
|
|
66
|
+
if (lastLines.includes(pattern.toLowerCase())) {
|
|
44
67
|
return { pattern, response, needsInput: true };
|
|
45
68
|
}
|
|
46
69
|
}
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
if (lastLine.endsWith(':') || lastLine.endsWith('?') ||
|
|
50
|
-
lastLine.includes('[y/n]') || lastLine.includes('(yes/no)')
|
|
70
|
+
|
|
71
|
+
// 3. Generic prompt detection
|
|
72
|
+
if (lastLine.endsWith(':') || lastLine.endsWith('?') ||
|
|
73
|
+
lastLine.includes('[y/n]') || lastLine.includes('(yes/no)') ||
|
|
74
|
+
lastLine.match(/^\s*\[\d+\]/) || lastLine.match(/^\s*\d+[.)]\s/) ||
|
|
75
|
+
lastLine.match(/choose|select|pick|enter|input/i)) {
|
|
51
76
|
return { pattern: 'generic', response: null, needsInput: true };
|
|
52
77
|
}
|
|
53
|
-
|
|
78
|
+
|
|
54
79
|
return { needsInput: false };
|
|
55
80
|
}
|
|
56
81
|
|
|
@@ -60,14 +85,23 @@ async function executeCommand(jobId) {
|
|
|
60
85
|
if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
|
|
61
86
|
|
|
62
87
|
let connection = null;
|
|
63
|
-
|
|
88
|
+
|
|
64
89
|
try {
|
|
65
90
|
const serverConfig = await serverManager.getServer(job.alias);
|
|
91
|
+
|
|
92
|
+
if (!job.skip_policy) {
|
|
93
|
+
const policyCheck = await policies.check(job.cmd);
|
|
94
|
+
if (policyCheck.blocked) {
|
|
95
|
+
queue.updateJobStatus(jobId, 'failed', { error: `Commande bloquée par la politique de sécurité (pattern: "${policyCheck.pattern}"). Utilisez skip_policy:true pour forcer.` });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
queue.updateJobStatus(jobId, 'running');
|
|
67
101
|
|
|
68
102
|
// Utiliser le pool de connexions si pas de mode interactif
|
|
69
103
|
const usePool = !job.interactive && job.persistent !== false;
|
|
70
|
-
|
|
104
|
+
|
|
71
105
|
if (usePool) {
|
|
72
106
|
// Obtenir une connexion du pool
|
|
73
107
|
connection = await sshPool.getConnection(job.alias, serverConfig);
|
|
@@ -76,9 +110,10 @@ async function executeCommand(jobId) {
|
|
|
76
110
|
// Créer une connexion dédiée pour les commandes interactives
|
|
77
111
|
await executeWithNewConnection(serverConfig, job, jobId);
|
|
78
112
|
}
|
|
79
|
-
|
|
113
|
+
|
|
80
114
|
} catch (err) {
|
|
81
|
-
queue.
|
|
115
|
+
const existing = queue.getJob(jobId) || {};
|
|
116
|
+
queue.updateJobStatus(jobId, 'failed', { ...existing, error: err.message });
|
|
82
117
|
} finally {
|
|
83
118
|
// Libérer la connexion si elle vient du pool
|
|
84
119
|
if (connection) {
|
|
@@ -88,7 +123,7 @@ async function executeCommand(jobId) {
|
|
|
88
123
|
}
|
|
89
124
|
|
|
90
125
|
// Exécution avec une connexion du pool
|
|
91
|
-
async function executeWithPooledConnection(connection, job, jobId) {
|
|
126
|
+
async function executeWithPooledConnection(connection, job, jobId, updateQueue = true) {
|
|
92
127
|
return new Promise((resolve, reject) => {
|
|
93
128
|
const { client } = connection;
|
|
94
129
|
|
|
@@ -119,38 +154,45 @@ async function executeWithPooledConnection(connection, job, jobId) {
|
|
|
119
154
|
reject(err);
|
|
120
155
|
return;
|
|
121
156
|
}
|
|
122
|
-
|
|
157
|
+
|
|
123
158
|
let output = '';
|
|
124
159
|
let stderr = '';
|
|
125
160
|
let lineCount = 0;
|
|
126
161
|
const startTime = Date.now();
|
|
127
162
|
const maxLines = job.maxLines || 1000;
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
163
|
+
const userTimeout = job.timeout; // en secondes, 0 = infini
|
|
164
|
+
const timeoutMs = userTimeout === 0 ? 0 : (userTimeout ? userTimeout * 1000 : config.defaultCommandTimeout);
|
|
165
|
+
|
|
166
|
+
let timeoutId = null;
|
|
167
|
+
if (timeoutMs > 0) {
|
|
168
|
+
timeoutId = setTimeout(() => {
|
|
169
|
+
stream.write('\x03');
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
stream.close();
|
|
172
|
+
const duration = Date.now() - startTime;
|
|
173
|
+
const result = { output: output.trim(), stderr: stderr.trim(), exitCode: 124, duration, lineCount, timedOut: true };
|
|
174
|
+
if (updateQueue) queue.updateJobStatus(jobId, 'completed', result);
|
|
175
|
+
resolve(result);
|
|
176
|
+
}, 500);
|
|
177
|
+
}, timeoutMs);
|
|
178
|
+
}
|
|
179
|
+
|
|
141
180
|
stream.on('close', (code, signal) => {
|
|
142
|
-
clearTimeout(timeoutId);
|
|
181
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
143
182
|
const duration = Date.now() - startTime;
|
|
144
183
|
const result = { output: output.trim(), stderr: stderr.trim(), exitCode: code, signal, duration, lineCount };
|
|
184
|
+
|
|
185
|
+
// Important: clear any other timeouts if they exist (though timeoutId is the main one here)
|
|
186
|
+
|
|
145
187
|
if (code === 0 || code === 124) {
|
|
146
|
-
queue.updateJobStatus(jobId, 'completed', result);
|
|
188
|
+
if (updateQueue) queue.updateJobStatus(jobId, 'completed', result);
|
|
147
189
|
resolve(result);
|
|
148
190
|
} else {
|
|
149
|
-
queue.updateJobStatus(jobId, 'failed', { ...result, error: `Commande terminée avec code ${code}` });
|
|
191
|
+
if (updateQueue) queue.updateJobStatus(jobId, 'failed', { ...result, error: `Commande terminée avec code ${code}` });
|
|
150
192
|
reject(new Error(`Exit code: ${code}`));
|
|
151
193
|
}
|
|
152
194
|
});
|
|
153
|
-
|
|
195
|
+
|
|
154
196
|
stream.on('data', (data) => {
|
|
155
197
|
const chunk = data.toString();
|
|
156
198
|
output += chunk;
|
|
@@ -160,14 +202,14 @@ async function executeWithPooledConnection(connection, job, jobId) {
|
|
|
160
202
|
setTimeout(() => stream.close(), 500);
|
|
161
203
|
}
|
|
162
204
|
if (job.autoRespond) {
|
|
163
|
-
const prompt = detectInteractivePrompt(output);
|
|
164
|
-
if (prompt.needsInput && prompt.response) {
|
|
205
|
+
const prompt = detectInteractivePrompt(output, job.responses || {});
|
|
206
|
+
if (prompt.needsInput && prompt.response && prompt.response !== 'PASSWORD_REQUIRED') {
|
|
165
207
|
stream.write(prompt.response + '\n');
|
|
166
208
|
queue.log('info', `Réponse automatique au prompt: ${prompt.pattern}`);
|
|
167
209
|
}
|
|
168
210
|
}
|
|
169
211
|
});
|
|
170
|
-
|
|
212
|
+
|
|
171
213
|
stream.stderr.on('data', (data) => {
|
|
172
214
|
stderr += data.toString();
|
|
173
215
|
});
|
|
@@ -193,22 +235,27 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
|
|
|
193
235
|
let responseSent = false;
|
|
194
236
|
|
|
195
237
|
const cleanupAndResolve = (status = 'completed') => {
|
|
196
|
-
clearTimeout(jobTimeout);
|
|
238
|
+
if (jobTimeout) clearTimeout(jobTimeout);
|
|
197
239
|
output = output.replace(END_MARKER, '').trim();
|
|
198
240
|
const result = { output, exitCode: 0 };
|
|
199
241
|
queue.updateJobStatus(jobId, status, result);
|
|
200
242
|
try {
|
|
201
243
|
stream.end();
|
|
202
244
|
conn.end();
|
|
203
|
-
} catch(e) {/* ignore */}
|
|
245
|
+
} catch (e) {/* ignore */ }
|
|
204
246
|
resolve(result);
|
|
205
247
|
};
|
|
206
248
|
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
249
|
+
const timeoutMs = job.timeout === 0 ? 0 : (job.timeout ? job.timeout * 1000 : config.interactiveCommandTimeout);
|
|
250
|
+
let jobTimeout = null;
|
|
251
|
+
if (timeoutMs > 0) {
|
|
252
|
+
jobTimeout = setTimeout(() => {
|
|
253
|
+
cleanupAndResolve('failed');
|
|
254
|
+
}, timeoutMs);
|
|
255
|
+
}
|
|
210
256
|
|
|
211
257
|
stream.on('close', () => {
|
|
258
|
+
if (jobTimeout) clearTimeout(jobTimeout);
|
|
212
259
|
if (!output.includes(END_MARKER)) {
|
|
213
260
|
cleanupAndResolve('failed');
|
|
214
261
|
}
|
|
@@ -226,7 +273,7 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
|
|
|
226
273
|
if (!responseSent && job.autoRespond) {
|
|
227
274
|
const lowerChunk = chunk.toLowerCase();
|
|
228
275
|
let responseToSend = null;
|
|
229
|
-
|
|
276
|
+
|
|
230
277
|
// Chercher une réponse personnalisée
|
|
231
278
|
for (const [pattern, response] of Object.entries(responses)) {
|
|
232
279
|
if (lowerChunk.includes(pattern.toLowerCase())) {
|
|
@@ -235,7 +282,7 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
|
|
|
235
282
|
break;
|
|
236
283
|
}
|
|
237
284
|
}
|
|
238
|
-
|
|
285
|
+
|
|
239
286
|
// Sinon, chercher une réponse par défaut
|
|
240
287
|
if (!responseToSend) {
|
|
241
288
|
for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
|
|
@@ -270,11 +317,11 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
|
|
|
270
317
|
reject(err);
|
|
271
318
|
});
|
|
272
319
|
|
|
273
|
-
const connectConfig = {
|
|
274
|
-
host: serverConfig.host,
|
|
275
|
-
port: 22,
|
|
276
|
-
username: serverConfig.user,
|
|
277
|
-
readyTimeout: 20000
|
|
320
|
+
const connectConfig = {
|
|
321
|
+
host: serverConfig.host,
|
|
322
|
+
port: 22,
|
|
323
|
+
username: serverConfig.user,
|
|
324
|
+
readyTimeout: 20000
|
|
278
325
|
};
|
|
279
326
|
|
|
280
327
|
(async () => {
|
|
@@ -302,17 +349,17 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
|
|
|
302
349
|
async function executeCommandSequence(jobId) {
|
|
303
350
|
const job = queue.getJob(jobId);
|
|
304
351
|
if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
|
|
305
|
-
|
|
352
|
+
|
|
306
353
|
const results = [];
|
|
307
354
|
let connection = null;
|
|
308
|
-
|
|
355
|
+
|
|
309
356
|
try {
|
|
310
357
|
const serverConfig = await serverManager.getServer(job.alias);
|
|
311
358
|
queue.updateJobStatus(jobId, 'running');
|
|
312
|
-
|
|
359
|
+
|
|
313
360
|
// Obtenir une connexion du pool
|
|
314
361
|
connection = await sshPool.getConnection(job.alias, serverConfig);
|
|
315
|
-
|
|
362
|
+
|
|
316
363
|
// Exécuter chaque commande en séquence
|
|
317
364
|
for (let i = 0; i < job.commands.length; i++) {
|
|
318
365
|
const cmd = job.commands[i];
|
|
@@ -322,36 +369,37 @@ async function executeCommandSequence(jobId) {
|
|
|
322
369
|
timeout: cmd.timeout || job.timeout,
|
|
323
370
|
continueOnError: cmd.continueOnError || job.continueOnError
|
|
324
371
|
};
|
|
325
|
-
|
|
372
|
+
|
|
326
373
|
try {
|
|
327
|
-
queue.log('info', `Exécution étape ${i+1}/${job.commands.length}: ${stepJob.cmd}`);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
374
|
+
queue.log('info', `Exécution étape ${i + 1}/${job.commands.length}: ${stepJob.cmd}`);
|
|
375
|
+
// Don't update the main queue for sub-steps with phantom IDs
|
|
376
|
+
const result = await executeWithPooledConnection(connection, stepJob, `${jobId}_step_${i}`, false);
|
|
377
|
+
results.push({
|
|
378
|
+
step: i + 1,
|
|
379
|
+
command: stepJob.cmd,
|
|
380
|
+
success: true,
|
|
381
|
+
...result
|
|
334
382
|
});
|
|
335
383
|
} catch (err) {
|
|
336
|
-
results.push({
|
|
337
|
-
step: i + 1,
|
|
338
|
-
command: stepJob.cmd,
|
|
339
|
-
success: false,
|
|
340
|
-
error: err.message
|
|
384
|
+
results.push({
|
|
385
|
+
step: i + 1,
|
|
386
|
+
command: stepJob.cmd,
|
|
387
|
+
success: false,
|
|
388
|
+
error: err.message
|
|
341
389
|
});
|
|
342
|
-
|
|
390
|
+
|
|
343
391
|
if (!stepJob.continueOnError) {
|
|
344
|
-
throw new Error(`Échec à l'étape ${i+1}: ${err.message}`);
|
|
392
|
+
throw new Error(`Échec à l'étape ${i + 1}: ${err.message}`);
|
|
345
393
|
}
|
|
346
394
|
}
|
|
347
395
|
}
|
|
348
|
-
|
|
396
|
+
|
|
349
397
|
queue.updateJobStatus(jobId, 'completed', { results });
|
|
350
|
-
|
|
398
|
+
|
|
351
399
|
} catch (err) {
|
|
352
|
-
queue.updateJobStatus(jobId, 'failed', {
|
|
353
|
-
error: err.message,
|
|
354
|
-
results
|
|
400
|
+
queue.updateJobStatus(jobId, 'failed', {
|
|
401
|
+
error: err.message,
|
|
402
|
+
results
|
|
355
403
|
});
|
|
356
404
|
} finally {
|
|
357
405
|
if (connection) {
|
|
@@ -419,7 +467,7 @@ function parseServicesStatus(output) {
|
|
|
419
467
|
|
|
420
468
|
try {
|
|
421
469
|
const sections = output.split(/---DOCKER---|---PM2---/);
|
|
422
|
-
const systemdRaw = sections[0] || '';
|
|
470
|
+
const systemdRaw = (sections[0] || '').replace(/^---SYSTEMD---\s*/, '');
|
|
423
471
|
const dockerRaw = sections[1] || '';
|
|
424
472
|
const pm2Raw = sections[2] || '';
|
|
425
473
|
|
|
@@ -467,26 +515,62 @@ function parseServicesStatus(output) {
|
|
|
467
515
|
}
|
|
468
516
|
|
|
469
517
|
function parseApiHealth(output) {
|
|
518
|
+
if (!output || typeof output !== 'string') {
|
|
519
|
+
return {
|
|
520
|
+
status: 'ERROR',
|
|
521
|
+
http_code: 0,
|
|
522
|
+
response_time_ms: 0,
|
|
523
|
+
error: 'Sortie invalide ou vide'
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
470
527
|
try {
|
|
471
|
-
const
|
|
528
|
+
const parts = output.trim().split(':');
|
|
529
|
+
if (parts.length !== 2) {
|
|
530
|
+
return {
|
|
531
|
+
status: 'ERROR',
|
|
532
|
+
http_code: 0,
|
|
533
|
+
response_time_ms: 0,
|
|
534
|
+
error: 'Format de réponse invalide',
|
|
535
|
+
raw_output: output
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const [codeStr, timeStr] = parts;
|
|
472
540
|
const http_code = parseInt(codeStr, 10);
|
|
473
541
|
const response_time_ms = parseFloat(timeStr) * 1000;
|
|
474
542
|
|
|
543
|
+
if (isNaN(http_code) || isNaN(response_time_ms)) {
|
|
544
|
+
return {
|
|
545
|
+
status: 'ERROR',
|
|
546
|
+
http_code: 0,
|
|
547
|
+
response_time_ms: 0,
|
|
548
|
+
error: 'Valeurs non numériques',
|
|
549
|
+
raw_output: output
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
475
553
|
return {
|
|
476
554
|
status: http_code >= 200 && http_code < 300 ? 'UP' : 'DOWN',
|
|
477
555
|
http_code: http_code,
|
|
478
556
|
response_time_ms: Math.round(response_time_ms)
|
|
479
557
|
};
|
|
480
558
|
} catch (e) {
|
|
481
|
-
return {
|
|
559
|
+
return {
|
|
560
|
+
status: 'ERROR',
|
|
561
|
+
http_code: 0,
|
|
562
|
+
response_time_ms: 0,
|
|
563
|
+
error: e.message,
|
|
564
|
+
raw_output: output
|
|
565
|
+
};
|
|
482
566
|
}
|
|
483
567
|
}
|
|
484
568
|
|
|
485
|
-
export default {
|
|
486
|
-
executeCommand,
|
|
487
|
-
executeCommandSequence,
|
|
488
|
-
getPoolStats,
|
|
489
|
-
parseSystemResources,
|
|
490
|
-
parseServicesStatus,
|
|
491
|
-
parseApiHealth
|
|
569
|
+
export default {
|
|
570
|
+
executeCommand,
|
|
571
|
+
executeCommandSequence,
|
|
572
|
+
getPoolStats,
|
|
573
|
+
parseSystemResources,
|
|
574
|
+
parseServicesStatus,
|
|
575
|
+
parseApiHealth
|
|
492
576
|
};
|