@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/sftp.js
CHANGED
|
@@ -4,6 +4,8 @@ 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';
|
|
8
10
|
|
|
9
11
|
// Fonction utilitaire pour créer un dossier parent si nécessaire
|
|
@@ -17,20 +19,31 @@ async function ensureLocalDir(filePath) {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
function hasGlobPattern(str) {
|
|
23
|
+
return /[*?[\]]/.test(str);
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
// Fonction pour créer un dossier distant si nécessaire
|
|
21
27
|
async function ensureRemoteDir(sftp, filePath) {
|
|
22
|
-
const dir = path.dirname(filePath);
|
|
28
|
+
const dir = path.posix.dirname(filePath); // Utiliser posix pour les chemins distants
|
|
29
|
+
|
|
30
|
+
if (dir === '/' || dir === '.') {
|
|
31
|
+
return; // Pas besoin de créer la racine
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
try {
|
|
24
35
|
const exists = await sftp.exists(dir);
|
|
25
36
|
if (!exists) {
|
|
26
|
-
await sftp.mkdir(dir, true);
|
|
37
|
+
await sftp.mkdir(dir, true); // true pour créer récursivement
|
|
27
38
|
queue.log('info', `Dossier distant créé: ${dir}`);
|
|
28
39
|
}
|
|
29
40
|
} catch (err) {
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
// Vérifier si c'est vraiment une erreur ou si le dossier existe déjà
|
|
42
|
+
const exists = await sftp.exists(dir).catch(() => false);
|
|
43
|
+
if (!exists) {
|
|
44
|
+
throw new Error(`Impossible de créer le dossier distant ${dir}: ${err.message}`);
|
|
33
45
|
}
|
|
46
|
+
// Sinon, le dossier existe, on continue
|
|
34
47
|
}
|
|
35
48
|
}
|
|
36
49
|
|
|
@@ -58,13 +71,13 @@ async function expandFileList(pattern, basePath = '') {
|
|
|
58
71
|
|
|
59
72
|
// Si c'est un pattern glob
|
|
60
73
|
if (pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) {
|
|
61
|
-
const fullPattern = path.
|
|
74
|
+
const fullPattern = path.resolve(normalizedBasePath, pattern);
|
|
62
75
|
const files = await glob(fullPattern, { nodir: false });
|
|
63
76
|
return files;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
// Sinon c'est un fichier/dossier simple
|
|
67
|
-
const fullPath = path.
|
|
80
|
+
const fullPath = path.resolve(normalizedBasePath, pattern);
|
|
68
81
|
return [fullPath];
|
|
69
82
|
}
|
|
70
83
|
|
|
@@ -104,29 +117,41 @@ async function executeTransfer(jobId) {
|
|
|
104
117
|
// Déterminer si on traite plusieurs fichiers
|
|
105
118
|
const files = job.files || [{ local: job.local, remote: job.remote }];
|
|
106
119
|
const isMultiple = Array.isArray(job.files) && job.files.length > 1;
|
|
107
|
-
|
|
120
|
+
const force = job.force === true;
|
|
121
|
+
|
|
108
122
|
let successCount = 0;
|
|
109
123
|
let failedFiles = [];
|
|
110
124
|
let totalFiles = 0;
|
|
111
|
-
|
|
112
|
-
// Traiter chaque fichier
|
|
125
|
+
|
|
113
126
|
for (let i = 0; i < files.length; i++) {
|
|
114
127
|
const file = files[i];
|
|
115
128
|
const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
|
|
116
|
-
|
|
129
|
+
|
|
117
130
|
try {
|
|
118
131
|
if (job.direction === 'upload') {
|
|
119
132
|
const localFiles = await expandFileList(file.local);
|
|
120
133
|
totalFiles += localFiles.length;
|
|
121
134
|
for (const localFile of localFiles) {
|
|
122
135
|
queue.log('info', `Transfert${progress}: ${localFile}`);
|
|
123
|
-
await handleUpload(sftp, localFile, file.remote);
|
|
136
|
+
await handleUpload(sftp, localFile, file.remote, force);
|
|
124
137
|
successCount++;
|
|
125
138
|
}
|
|
126
139
|
} else if (job.direction === 'download') {
|
|
127
|
-
const downloadedCount = await handleDownload(sftp, file.remote, file.local);
|
|
140
|
+
const downloadedCount = await handleDownload(sftp, file.remote, file.local, force);
|
|
128
141
|
totalFiles += downloadedCount;
|
|
129
142
|
successCount += downloadedCount;
|
|
143
|
+
} else if (job.direction === 'server_to_server') {
|
|
144
|
+
// Transfert direct entre deux serveurs distants (ou local↔remote inversé)
|
|
145
|
+
const srcAlias = file.source_alias || job.source_alias;
|
|
146
|
+
const srcPath = file.source_path || file.local || file.remote;
|
|
147
|
+
const tgtAlias = job.alias;
|
|
148
|
+
const tgtPath = file.remote;
|
|
149
|
+
|
|
150
|
+
queue.log('info', `Transfert server_to_server${progress}: ${srcAlias}:${srcPath} → ${tgtAlias}:${tgtPath}`);
|
|
151
|
+
const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: srcPath });
|
|
152
|
+
await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: tgtPath }, buf.content, { createDirs: true });
|
|
153
|
+
successCount++;
|
|
154
|
+
totalFiles++;
|
|
130
155
|
}
|
|
131
156
|
} catch (err) {
|
|
132
157
|
queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
|
|
@@ -161,31 +186,63 @@ async function executeTransfer(jobId) {
|
|
|
161
186
|
}
|
|
162
187
|
|
|
163
188
|
// Gestion spécifique de l'upload
|
|
164
|
-
async function handleUpload(sftp, localPath, remotePath) {
|
|
189
|
+
async function handleUpload(sftp, localPath, remotePath, force = false) {
|
|
190
|
+
let localStats;
|
|
165
191
|
try {
|
|
166
|
-
|
|
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
|
-
}
|
|
192
|
+
localStats = await fs.stat(localPath);
|
|
176
193
|
} catch (err) {
|
|
177
|
-
// Si le fichier local n'existe pas
|
|
178
194
|
if (err.code === 'ENOENT') {
|
|
179
195
|
throw new Error(`Fichier local introuvable: ${localPath}`);
|
|
180
196
|
}
|
|
181
197
|
throw err;
|
|
182
198
|
}
|
|
199
|
+
|
|
200
|
+
if (localStats.isDirectory()) {
|
|
201
|
+
const remoteExists = await sftp.exists(remotePath).catch(() => false);
|
|
202
|
+
if (remoteExists) {
|
|
203
|
+
try {
|
|
204
|
+
const remoteStats = await sftp.stat(remotePath);
|
|
205
|
+
if (!remoteStats.isDirectory) {
|
|
206
|
+
throw new Error(`Impossible d'envoyer un dossier local vers un fichier distant existant: ${remotePath}. Spécifiez un dossier de destination.`);
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {
|
|
209
|
+
if (e.message && e.message.includes('Impossible d')) throw e;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await sftp.uploadDir(localPath, remotePath);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let finalRemotePath;
|
|
217
|
+
const remoteExists = await sftp.exists(remotePath).catch(() => false);
|
|
218
|
+
|
|
219
|
+
if (remoteExists) {
|
|
220
|
+
let remoteStats;
|
|
221
|
+
try {
|
|
222
|
+
remoteStats = await sftp.stat(remotePath);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
remoteStats = null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (remoteStats && remoteStats.isDirectory) {
|
|
228
|
+
finalRemotePath = path.posix.join(remotePath, path.basename(localPath));
|
|
229
|
+
} else {
|
|
230
|
+
if (!force) {
|
|
231
|
+
throw new Error(`Le fichier distant ${remotePath} existe déjà. Utilisez force:true pour l'écraser.`);
|
|
232
|
+
}
|
|
233
|
+
finalRemotePath = remotePath;
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
finalRemotePath = remotePath;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await ensureRemoteDir(sftp, finalRemotePath);
|
|
240
|
+
await sftp.put(localPath, finalRemotePath);
|
|
183
241
|
}
|
|
184
242
|
|
|
185
243
|
// Gestion spécifique du download
|
|
186
|
-
async function handleDownload(sftp, remotePath, localPath) {
|
|
187
|
-
|
|
188
|
-
if (micromatch.isMatch(remotePath, '**/*')) {
|
|
244
|
+
async function handleDownload(sftp, remotePath, localPath, force = false) {
|
|
245
|
+
if (hasGlobPattern(remotePath)) {
|
|
189
246
|
const parentDir = path.dirname(remotePath);
|
|
190
247
|
const pattern = path.basename(remotePath);
|
|
191
248
|
|
|
@@ -197,20 +254,22 @@ async function handleDownload(sftp, remotePath, localPath) {
|
|
|
197
254
|
throw new Error(`Aucun fichier distant ne correspond au pattern: ${remotePath}`);
|
|
198
255
|
}
|
|
199
256
|
|
|
200
|
-
|
|
201
|
-
await ensureLocalDir(localPath);
|
|
257
|
+
await fs.mkdir(localPath, { recursive: true });
|
|
202
258
|
|
|
203
|
-
// Télécharger chaque fichier correspondant
|
|
204
259
|
for (const fileName of matchingFiles) {
|
|
205
260
|
const remoteFile = path.join(parentDir, fileName);
|
|
206
261
|
const localFile = path.join(localPath, fileName);
|
|
262
|
+
|
|
263
|
+
const localFileExists = await fs.access(localFile).then(() => true).catch(() => false);
|
|
264
|
+
if (localFileExists && !force) {
|
|
265
|
+
throw new Error(`Le fichier local ${localFile} existe déjà. Utilisez force:true pour l'écraser.`);
|
|
266
|
+
}
|
|
267
|
+
|
|
207
268
|
queue.log('info', `Téléchargement (glob): ${remoteFile} -> ${localFile}`);
|
|
208
269
|
await sftp.get(remoteFile, localFile);
|
|
209
270
|
}
|
|
210
|
-
// Retourner le nombre de fichiers téléchargés pour le rapport
|
|
211
271
|
return matchingFiles.length;
|
|
212
272
|
} catch (err) {
|
|
213
|
-
// Si le dossier parent n'existe pas, on propage l'erreur
|
|
214
273
|
if (err.code === 2) {
|
|
215
274
|
throw new Error(`Le dossier parent pour le glob n'existe pas: ${parentDir}`);
|
|
216
275
|
}
|
|
@@ -218,21 +277,52 @@ async function handleDownload(sftp, remotePath, localPath) {
|
|
|
218
277
|
}
|
|
219
278
|
|
|
220
279
|
} else {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (!exists) {
|
|
280
|
+
const remoteExists = await sftp.exists(remotePath);
|
|
281
|
+
if (!remoteExists) {
|
|
224
282
|
throw new Error(`Fichier distant introuvable: ${remotePath}`);
|
|
225
283
|
}
|
|
226
284
|
|
|
227
|
-
const
|
|
228
|
-
await ensureLocalDir(localPath);
|
|
285
|
+
const remoteStats = await sftp.stat(remotePath);
|
|
229
286
|
|
|
230
|
-
if (
|
|
287
|
+
if (remoteStats.isDirectory) {
|
|
288
|
+
await fs.mkdir(localPath, { recursive: true });
|
|
231
289
|
await sftp.downloadDir(remotePath, localPath);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let finalLocalPath;
|
|
294
|
+
const localExists = await fs.access(localPath).then(() => true).catch(() => false);
|
|
295
|
+
|
|
296
|
+
if (localExists) {
|
|
297
|
+
let localStats;
|
|
298
|
+
try {
|
|
299
|
+
localStats = await fs.stat(localPath);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
localStats = null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (localStats && localStats.isDirectory()) {
|
|
305
|
+
finalLocalPath = path.join(localPath, path.basename(remotePath));
|
|
306
|
+
} else {
|
|
307
|
+
if (!force) {
|
|
308
|
+
throw new Error(`Le fichier local ${localPath} existe déjà. Utilisez force:true pour l'écraser.`);
|
|
309
|
+
}
|
|
310
|
+
finalLocalPath = localPath;
|
|
311
|
+
}
|
|
232
312
|
} else {
|
|
233
|
-
|
|
313
|
+
const hasExt = path.extname(localPath) !== '';
|
|
314
|
+
if (hasExt) {
|
|
315
|
+
finalLocalPath = localPath;
|
|
316
|
+
} else {
|
|
317
|
+
await fs.mkdir(localPath, { recursive: true });
|
|
318
|
+
finalLocalPath = path.join(localPath, path.basename(remotePath));
|
|
319
|
+
}
|
|
234
320
|
}
|
|
235
|
-
|
|
321
|
+
|
|
322
|
+
const localDir = path.dirname(finalLocalPath);
|
|
323
|
+
await fs.mkdir(localDir, { recursive: true });
|
|
324
|
+
await sftp.get(remotePath, finalLocalPath);
|
|
325
|
+
return 1;
|
|
236
326
|
}
|
|
237
327
|
}
|
|
238
328
|
|
package/shellSessions.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Client } from 'ssh2';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import serverManager from './servers.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* shellSessions — Sessions shell PTY PERSISTANTES sur serveurs distants.
|
|
8
|
+
*
|
|
9
|
+
* Contrairement à task_exec (client.exec() = nouveau shell isolé à chaque appel),
|
|
10
|
+
* ici on ouvre un vrai shell interactif (client.shell()) maintenu ouvert. Ainsi
|
|
11
|
+
* `cd`, `export`, activations d'environnement, etc. PERSISTENT entre les commandes.
|
|
12
|
+
*
|
|
13
|
+
* === Détection de fin de commande (technique du marqueur echo-agnostique) ===
|
|
14
|
+
* Après chaque commande, on envoie : printf '%s:%s\n' "MARKER" "$?"
|
|
15
|
+
* - La sortie RÉELLE du printf donne "MARKER:0" (exit code résolu en nombre).
|
|
16
|
+
* - La ligne de commande ÉCHO (si echo actif) contient "MARKER:$?" (littéral).
|
|
17
|
+
* - La regex /MARKER:(\d+)/ ne matche QUE la sortie réelle → jamais l'écho.
|
|
18
|
+
* De plus on désactive l'écho du PTY (stty -echo) dès l'ouverture pour un parsing net.
|
|
19
|
+
*
|
|
20
|
+
* Chaque session utilise une connexion SSH DÉDIÉE (pas le pool) car le shell
|
|
21
|
+
* monopolise le canal pour toute sa durée de vie.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_IDLE_TIMEOUT = 30 * 60 * 1000; // 30 min d'inactivité → fermeture auto
|
|
25
|
+
const DEFAULT_CMD_TIMEOUT = 300; // secondes
|
|
26
|
+
|
|
27
|
+
class ShellSessionManager {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.sessions = new Map(); // id → session
|
|
30
|
+
this._startCleanup();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async _buildConnectConfig(alias) {
|
|
34
|
+
const sc = await serverManager.getServer(alias);
|
|
35
|
+
const config = {
|
|
36
|
+
host: sc.host,
|
|
37
|
+
port: sc.port || 22,
|
|
38
|
+
username: sc.user,
|
|
39
|
+
readyTimeout: 20000,
|
|
40
|
+
keepaliveInterval: 30000,
|
|
41
|
+
keepaliveCountMax: 3
|
|
42
|
+
};
|
|
43
|
+
if (sc.keyPath) {
|
|
44
|
+
config.privateKey = await fs.readFile(sc.keyPath);
|
|
45
|
+
} else if (sc.password) {
|
|
46
|
+
config.password = sc.password;
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`Aucune méthode d'authentification pour le serveur '${alias}'.`);
|
|
49
|
+
}
|
|
50
|
+
return config;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Crée une session shell persistante.
|
|
55
|
+
* options = { workdir, env: {K:V}, cmdTimeout }
|
|
56
|
+
* Retourne { id, alias, ready }.
|
|
57
|
+
*/
|
|
58
|
+
async createSession(alias, options = {}) {
|
|
59
|
+
const config = await this._buildConnectConfig(alias);
|
|
60
|
+
const conn = new Client();
|
|
61
|
+
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
let settled = false;
|
|
64
|
+
conn.on('ready', () => { if (!settled) { settled = true; resolve(); } });
|
|
65
|
+
conn.on('error', (err) => { if (!settled) { settled = true; reject(err); } });
|
|
66
|
+
conn.connect(config);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const stream = await new Promise((resolve, reject) => {
|
|
70
|
+
conn.shell({ term: 'xterm', modes: {} }, (err, s) => err ? reject(err) : resolve(s));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const id = `sh_${alias}_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
|
74
|
+
const session = {
|
|
75
|
+
id, alias, conn, stream,
|
|
76
|
+
buffer: '',
|
|
77
|
+
createdAt: Date.now(),
|
|
78
|
+
lastUsed: Date.now(),
|
|
79
|
+
commandCount: 0,
|
|
80
|
+
busy: false,
|
|
81
|
+
closed: false,
|
|
82
|
+
cmdTimeout: options.cmdTimeout || DEFAULT_CMD_TIMEOUT
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
stream.on('data', (d) => { session.buffer += d.toString('utf8'); });
|
|
86
|
+
stream.on('close', () => { session.closed = true; });
|
|
87
|
+
conn.on('error', () => { session.closed = true; });
|
|
88
|
+
|
|
89
|
+
this.sessions.set(id, session);
|
|
90
|
+
|
|
91
|
+
// Setup : désactive l'écho (parsing net) et vide le prompt
|
|
92
|
+
await this._exec(session, "stty -echo 2>/dev/null; export PS1=''; export PS2=''", 15);
|
|
93
|
+
|
|
94
|
+
// Répertoire de travail initial
|
|
95
|
+
if (options.workdir) {
|
|
96
|
+
const r = await this._exec(session, `cd ${this._shellQuote(options.workdir)}`, 15);
|
|
97
|
+
if (r.exitCode !== 0) {
|
|
98
|
+
this.closeSession(id);
|
|
99
|
+
throw new Error(`Impossible de cd vers '${options.workdir}': ${r.output}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Variables d'environnement initiales
|
|
104
|
+
if (options.env && typeof options.env === 'object') {
|
|
105
|
+
for (const [k, v] of Object.entries(options.env)) {
|
|
106
|
+
await this._exec(session, `export ${k}=${this._shellQuote(String(v))}`, 15);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { id, alias, ready: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_shellQuote(str) {
|
|
114
|
+
// Quote sûr pour bash : entoure de ' et échappe les ' internes
|
|
115
|
+
return `'${String(str).replace(/'/g, `'\\''`)}'`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Exécute une commande dans la session et attend le marqueur de fin.
|
|
120
|
+
* Retourne { output, exitCode, timedOut }.
|
|
121
|
+
*/
|
|
122
|
+
async _exec(session, command, timeoutSec) {
|
|
123
|
+
const marker = `__ORCH_${crypto.randomBytes(8).toString('hex')}__`;
|
|
124
|
+
const re = new RegExp(`${marker}:(-?\\d+)`);
|
|
125
|
+
|
|
126
|
+
// Vide le buffer juste avant d'écrire (les commandes sont sérialisées via busy)
|
|
127
|
+
session.buffer = '';
|
|
128
|
+
|
|
129
|
+
// Commande utilisateur, puis marqueur avec code de sortie de CETTE commande
|
|
130
|
+
session.stream.write(`${command}\n`);
|
|
131
|
+
session.stream.write(`printf '%s:%s\\n' "${marker}" "$?"\n`);
|
|
132
|
+
|
|
133
|
+
const timeoutMs = timeoutSec === 0 ? 0 : (timeoutSec || DEFAULT_CMD_TIMEOUT) * 1000;
|
|
134
|
+
const start = Date.now();
|
|
135
|
+
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const check = setInterval(() => {
|
|
138
|
+
const m = session.buffer.match(re);
|
|
139
|
+
if (m) {
|
|
140
|
+
clearInterval(check);
|
|
141
|
+
const exitCode = parseInt(m[1], 10);
|
|
142
|
+
const raw = session.buffer.slice(0, m.index);
|
|
143
|
+
resolve({ output: this._clean(raw, marker).trim(), exitCode, timedOut: false });
|
|
144
|
+
} else if (session.closed) {
|
|
145
|
+
clearInterval(check);
|
|
146
|
+
resolve({ output: this._clean(session.buffer, marker).trim(), exitCode: null, timedOut: false, closed: true });
|
|
147
|
+
} else if (timeoutMs > 0 && Date.now() - start > timeoutMs) {
|
|
148
|
+
clearInterval(check);
|
|
149
|
+
// Tente de débloquer la commande (Ctrl-C) sans tuer la session
|
|
150
|
+
try { session.stream.write('\x03'); } catch (e) { /* ignore */ }
|
|
151
|
+
resolve({ output: this._clean(session.buffer, marker).trim(), exitCode: null, timedOut: true });
|
|
152
|
+
}
|
|
153
|
+
}, 100);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Nettoie la sortie : strip séquences ANSI (couleurs, bracketed-paste \x1b[?2004h/l),
|
|
158
|
+
// strip les \r du PTY, retire les lignes écho/marqueur.
|
|
159
|
+
_clean(output, marker) {
|
|
160
|
+
return output
|
|
161
|
+
// eslint-disable-next-line no-control-regex
|
|
162
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // séquences CSI ANSI
|
|
163
|
+
.replace(/\r/g, '')
|
|
164
|
+
.split('\n')
|
|
165
|
+
.filter(line => !line.includes(marker) && !line.includes("printf '%s:%s"))
|
|
166
|
+
.join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Exécute une commande dans une session existante (état persistant).
|
|
171
|
+
*/
|
|
172
|
+
async execInSession(id, command, timeoutSec) {
|
|
173
|
+
const session = this.sessions.get(id);
|
|
174
|
+
if (!session) throw new Error(`Session '${id}' introuvable. Utilisez shell_list pour voir les sessions actives.`);
|
|
175
|
+
if (session.closed) {
|
|
176
|
+
this.sessions.delete(id);
|
|
177
|
+
throw new Error(`Session '${id}' est fermée (connexion perdue). Créez-en une nouvelle avec shell_create.`);
|
|
178
|
+
}
|
|
179
|
+
if (session.busy) throw new Error(`Session '${id}' occupée : une seule commande à la fois par session.`);
|
|
180
|
+
|
|
181
|
+
session.busy = true;
|
|
182
|
+
session.lastUsed = Date.now();
|
|
183
|
+
session.commandCount++;
|
|
184
|
+
try {
|
|
185
|
+
return await this._exec(session, command, timeoutSec ?? session.cmdTimeout);
|
|
186
|
+
} finally {
|
|
187
|
+
session.busy = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Liste les sessions actives avec leur état.
|
|
193
|
+
*/
|
|
194
|
+
listSessions() {
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
return Array.from(this.sessions.values()).map(s => ({
|
|
197
|
+
id: s.id,
|
|
198
|
+
alias: s.alias,
|
|
199
|
+
ageSeconds: Math.round((now - s.createdAt) / 1000),
|
|
200
|
+
idleSeconds: Math.round((now - s.lastUsed) / 1000),
|
|
201
|
+
commandCount: s.commandCount,
|
|
202
|
+
busy: s.busy,
|
|
203
|
+
closed: s.closed
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Ferme proprement une session.
|
|
209
|
+
*/
|
|
210
|
+
closeSession(id) {
|
|
211
|
+
const session = this.sessions.get(id);
|
|
212
|
+
if (!session) throw new Error(`Session '${id}' introuvable.`);
|
|
213
|
+
try { session.stream.end('exit\n'); } catch (e) { /* ignore */ }
|
|
214
|
+
try { session.conn.end(); } catch (e) { /* ignore */ }
|
|
215
|
+
this.sessions.delete(id);
|
|
216
|
+
return { closed: true, id };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_startCleanup() {
|
|
220
|
+
setInterval(() => {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
for (const [id, s] of this.sessions) {
|
|
223
|
+
if (s.closed || (!s.busy && (now - s.lastUsed) > DEFAULT_IDLE_TIMEOUT)) {
|
|
224
|
+
try { this.closeSession(id); } catch (e) { /* ignore */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}, 60000);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
closeAll() {
|
|
231
|
+
for (const id of [...this.sessions.keys()]) {
|
|
232
|
+
try { this.closeSession(id); } catch (e) { /* ignore */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default new ShellSessionManager();
|