@fkom13/mcp-sftp-orchestrator 11.3.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 +6 -0
- package/CHANGELOG.md +174 -0
- package/README.fr.md +230 -243
- package/README.md +268 -286
- package/apis.js +20 -81
- package/atomicJsonStore.js +117 -0
- package/config.js +5 -0
- package/fleet.js +170 -0
- package/groups.js +25 -0
- package/history.js +5 -51
- package/infraTopology.js +336 -0
- package/inventory.js +151 -0
- package/lhm.plugin.json +7 -0
- package/notes.js +3 -87
- package/package.json +8 -6
- package/policies.js +6 -74
- package/projects.js +108 -0
- package/queue.js +83 -14
- package/server.js +821 -90
- package/servers.js +30 -33
- package/sftp.js +168 -60
- package/shellSessions.js +31 -3
- package/snapshotManager.js +3 -7
- package/sourceAdapter.js +54 -1
- package/ssh.js +17 -3
- package/sshPool.js +129 -112
- package/sshTrust.js +214 -0
- package/toolMetadata.js +76 -0
- package/tunnels.js +36 -17
- package/utils.js +210 -1
- package/workSession.js +139 -0
- package/.gencodedoc.yaml +0 -76
- package/ROADMAP.md +0 -21
- package/test_features.js +0 -112
- package/test_mcp.js +0 -55
package/tunnels.js
CHANGED
|
@@ -5,6 +5,8 @@ import config from './config.js';
|
|
|
5
5
|
import queue from './queue.js';
|
|
6
6
|
import history from './history.js';
|
|
7
7
|
import ssh from './ssh.js';
|
|
8
|
+
import utils from './utils.js';
|
|
9
|
+
import jsonStore from './atomicJsonStore.js';
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
const TUNNELS_PATH = path.join(config.dataDir, 'tunnels.json');
|
|
@@ -15,27 +17,38 @@ const DEFAULT_ALLOWLIST = [80, 443, 3000, 8080, 8443, 9090, 3002, 3100, 3102, 45
|
|
|
15
17
|
const LOCAL_TUNNELS = new Map();
|
|
16
18
|
|
|
17
19
|
async function loadJson(p, def) {
|
|
18
|
-
|
|
19
|
-
catch { return def; }
|
|
20
|
+
return jsonStore.readJson(p, def);
|
|
20
21
|
}
|
|
21
22
|
async function saveJson(p, data) {
|
|
22
|
-
await
|
|
23
|
+
await jsonStore.writeJsonAtomic(p, data);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
async function loadAllowlist() {
|
|
26
|
-
return
|
|
27
|
+
return loadJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST);
|
|
27
28
|
}
|
|
28
29
|
async function saveAllowlist(list) {
|
|
29
30
|
await saveJson(ALLOWLIST_PATH, list);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
async function loadRegistry() {
|
|
33
|
-
return
|
|
34
|
+
return loadJson(TUNNELS_PATH, {});
|
|
34
35
|
}
|
|
35
36
|
async function saveRegistry(reg) {
|
|
36
37
|
await saveJson(TUNNELS_PATH, reg);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
const tunnelLocks = new Map();
|
|
41
|
+
async function withTunnelLock(name, fn) {
|
|
42
|
+
const prev = tunnelLocks.get(name) || Promise.resolve();
|
|
43
|
+
let release;
|
|
44
|
+
const gate = new Promise(r => { release = r; });
|
|
45
|
+
const tail = prev.catch(() => {}).then(() => gate);
|
|
46
|
+
tunnelLocks.set(name, tail);
|
|
47
|
+
await prev.catch(() => {});
|
|
48
|
+
try { return await fn(); }
|
|
49
|
+
finally { release(); if (tunnelLocks.get(name) === tail) tunnelLocks.delete(name); }
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
async function getSshTarget(alias, serverManager) {
|
|
40
53
|
const cfg = await serverManager.getServer(alias);
|
|
41
54
|
return { host: cfg.host, port: cfg.port || 22, user: cfg.user, keyPath: cfg.keyPath };
|
|
@@ -67,6 +80,7 @@ function buildArgs(type, listenPort, target, via, remoteKeyPath) {
|
|
|
67
80
|
|
|
68
81
|
export default {
|
|
69
82
|
async create(params, serverManager) {
|
|
83
|
+
return withTunnelLock(params.name, async () => {
|
|
70
84
|
const { name, type, listen_port, target, via, source, key_path } = params;
|
|
71
85
|
if (!name) throw new Error("Le paramètre 'name' est requis.");
|
|
72
86
|
if (!['local', 'remote', 'socks'].includes(type)) throw new Error("type doit être 'local', 'remote' ou 'socks'");
|
|
@@ -99,8 +113,8 @@ export default {
|
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
const sessionName = `tunnel-${name}`;
|
|
102
|
-
const sshCmd = 'ssh
|
|
103
|
-
const createCmd = `
|
|
116
|
+
const sshCmd = ['ssh', ...args].map(utils.escapeShellArg).join(' ');
|
|
117
|
+
const createCmd = `tmux new-session -d -s ${utils.escapeShellArg(sessionName)} -- sh -lc ${utils.escapeShellArg(sshCmd)}`;
|
|
104
118
|
|
|
105
119
|
const job = queue.addJob({ type: 'ssh', alias: source, cmd: createCmd, pty: true, timeout: 20, skip_policy: true, status: 'pending' });
|
|
106
120
|
history.logTask(job);
|
|
@@ -130,6 +144,12 @@ export default {
|
|
|
130
144
|
? `Proxy SOCKS5 sur ${source}:${listen_port} (via ${via})`
|
|
131
145
|
: `${source}:${listen_port} → ${via}:${target}`;
|
|
132
146
|
return `Tunnel '${name}' créé sur ${source} (session tmux: ${sessionName}). ${desc}\n✅ Persistant (survit au redémarrage du MCP).`;
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async get(name) {
|
|
151
|
+
const registry = await loadRegistry();
|
|
152
|
+
return registry[name] || null;
|
|
133
153
|
},
|
|
134
154
|
|
|
135
155
|
async list() {
|
|
@@ -142,7 +162,7 @@ export default {
|
|
|
142
162
|
const local = LOCAL_TUNNELS.get(name);
|
|
143
163
|
status = local && local.process && !local.process.killed ? 'actif' : 'mort (MCP relancé, relancez le tunnel)';
|
|
144
164
|
} else {
|
|
145
|
-
const ckJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux has-session -t ${info.tmux_session} 2>/dev/null && echo actif || echo mort`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
|
|
165
|
+
const ckJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux has-session -t ${utils.escapeShellArg(info.tmux_session)} 2>/dev/null && echo actif || echo mort`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
|
|
146
166
|
ssh.executeCommand(ckJob.id);
|
|
147
167
|
await new Promise(r => { const p = () => { const j = queue.getJob(ckJob.id); if (!j || j.status === 'completed' || j.status === 'failed') r(j); else setTimeout(p, 200); }; p(); });
|
|
148
168
|
const j = queue.getJob(ckJob.id);
|
|
@@ -156,6 +176,7 @@ export default {
|
|
|
156
176
|
},
|
|
157
177
|
|
|
158
178
|
async close(name, serverManager) {
|
|
179
|
+
return withTunnelLock(name, async () => {
|
|
159
180
|
const registry = await loadRegistry();
|
|
160
181
|
const info = registry[name];
|
|
161
182
|
if (!info) throw new Error(`Tunnel '${name}' introuvable.`);
|
|
@@ -168,13 +189,14 @@ export default {
|
|
|
168
189
|
}
|
|
169
190
|
LOCAL_TUNNELS.delete(name);
|
|
170
191
|
} else {
|
|
171
|
-
const killJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux kill-session -t ${info.tmux_session} 2>/dev/null; echo OK`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
|
|
192
|
+
const killJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux kill-session -t ${utils.escapeShellArg(info.tmux_session)} 2>/dev/null; echo OK`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
|
|
172
193
|
ssh.executeCommand(killJob.id);
|
|
173
194
|
}
|
|
174
195
|
|
|
175
196
|
delete registry[name];
|
|
176
197
|
await saveRegistry(registry);
|
|
177
198
|
return `Tunnel '${name}' fermé.`;
|
|
199
|
+
});
|
|
178
200
|
},
|
|
179
201
|
|
|
180
202
|
async allowlistList() {
|
|
@@ -182,17 +204,14 @@ export default {
|
|
|
182
204
|
},
|
|
183
205
|
|
|
184
206
|
async allowlistAdd(port) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
207
|
+
return jsonStore.updateJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST, list => {
|
|
208
|
+
if (!list.includes(port)) list.push(port);
|
|
209
|
+
return list;
|
|
210
|
+
});
|
|
189
211
|
},
|
|
190
212
|
|
|
191
213
|
async allowlistRemove(port) {
|
|
192
|
-
|
|
193
|
-
const filtered = list.filter(p => p !== port);
|
|
194
|
-
await saveAllowlist(filtered);
|
|
195
|
-
return filtered;
|
|
214
|
+
return jsonStore.updateJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST, list => list.filter(p => p !== port));
|
|
196
215
|
},
|
|
197
216
|
|
|
198
217
|
async restore(servers) {
|
package/utils.js
CHANGED
|
@@ -1,6 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitaires partagés (échappement shell, masquage secrets, timeouts, statuts).
|
|
3
|
+
* Extrait pour tests unitaires sans charger le serveur MCP.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SENSITIVE_KEYS = new Set([
|
|
7
|
+
'password',
|
|
8
|
+
'pass',
|
|
9
|
+
'api_key',
|
|
10
|
+
'apikey',
|
|
11
|
+
'apiKey',
|
|
12
|
+
'htpasswd_pass',
|
|
13
|
+
'htpasswdPass',
|
|
14
|
+
'secret',
|
|
15
|
+
'token',
|
|
16
|
+
'access_token',
|
|
17
|
+
'privateKey',
|
|
18
|
+
'private_key',
|
|
19
|
+
'authorization'
|
|
20
|
+
]);
|
|
21
|
+
|
|
1
22
|
function escapeShellArg(arg) {
|
|
2
23
|
if (typeof arg !== 'string') return String(arg);
|
|
3
24
|
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
4
25
|
}
|
|
5
26
|
|
|
6
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Masque un secret : garde les `visible` derniers caractères.
|
|
29
|
+
* null/undefined/non-string → inchangé.
|
|
30
|
+
*/
|
|
31
|
+
function maskSecret(value, visible = 4) {
|
|
32
|
+
if (value == null) return value;
|
|
33
|
+
const s = String(value);
|
|
34
|
+
if (s.length === 0) return s;
|
|
35
|
+
if (s.length <= visible) return '***';
|
|
36
|
+
return '***' + s.slice(-visible);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Clone profond léger et masque les champs sensibles (récursif).
|
|
41
|
+
* Ne mute pas l'objet d'origine.
|
|
42
|
+
*/
|
|
43
|
+
function redactSensitiveObject(obj, options = {}) {
|
|
44
|
+
const visible = options.visible ?? 4;
|
|
45
|
+
const extraKeys = options.extraKeys || [];
|
|
46
|
+
const keys = new Set([...SENSITIVE_KEYS, ...extraKeys.map(k => k.toLowerCase())]);
|
|
47
|
+
|
|
48
|
+
function walk(value, keyName) {
|
|
49
|
+
if (value == null) return value;
|
|
50
|
+
if (Array.isArray(value)) return value.map(v => walk(v, null));
|
|
51
|
+
if (typeof value === 'object') {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of Object.entries(value)) {
|
|
54
|
+
const lower = k.toLowerCase();
|
|
55
|
+
if (keys.has(lower) || keys.has(k)) {
|
|
56
|
+
out[k] = typeof v === 'string' || typeof v === 'number'
|
|
57
|
+
? maskSecret(v, visible)
|
|
58
|
+
: (v == null ? v : '***');
|
|
59
|
+
} else {
|
|
60
|
+
out[k] = walk(v, k);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return walk(obj, null);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convertit un timeout "secondes utilisateur" en ms pour waitForJobCompletion.
|
|
73
|
+
* - undefined/null → fallbackMs (déjà en ms, ex: config.syncTimeout)
|
|
74
|
+
* - 0 → 0 (attente infinie côté wait)
|
|
75
|
+
* - n > 0 → n * 1000
|
|
76
|
+
*/
|
|
77
|
+
function toWaitTimeoutMs(timeoutSeconds, fallbackMs) {
|
|
78
|
+
if (timeoutSeconds === 0) return 0;
|
|
79
|
+
if (timeoutSeconds == null || timeoutSeconds === undefined || timeoutSeconds === '') {
|
|
80
|
+
return fallbackMs;
|
|
81
|
+
}
|
|
82
|
+
const n = Number(timeoutSeconds);
|
|
83
|
+
if (!Number.isFinite(n) || n < 0) return fallbackMs;
|
|
84
|
+
return n * 1000;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Statuts pour lesquels un job n'évoluera plus (fin d'attente hybride). */
|
|
88
|
+
function isTerminalJobStatus(status) {
|
|
89
|
+
return status === 'completed'
|
|
90
|
+
|| status === 'failed'
|
|
91
|
+
|| status === 'partial'
|
|
92
|
+
|| status === 'crashed';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Résout le port SSH depuis une config serveur.
|
|
97
|
+
*/
|
|
98
|
+
function resolveSshPort(serverConfig) {
|
|
99
|
+
if (!serverConfig) return 22;
|
|
100
|
+
const p = serverConfig.port;
|
|
101
|
+
if (p == null || p === '') return 22;
|
|
102
|
+
const n = Number(p);
|
|
103
|
+
return Number.isFinite(n) && n > 0 && n <= 65535 ? n : 22;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Patterns heuristiques de commandes destructives (avertissement / dry-run). */
|
|
107
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
108
|
+
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/i,
|
|
109
|
+
/\brm\s+-rf\b/i,
|
|
110
|
+
/\bmkfs\b/i,
|
|
111
|
+
/\bdd\s+if=/i,
|
|
112
|
+
/\bdrop\s+(database|table)\b/i,
|
|
113
|
+
/\btruncate\s+table\b/i,
|
|
114
|
+
/\bshutdown\b/i,
|
|
115
|
+
/\breboot\b/i,
|
|
116
|
+
/\buserdel\b/i,
|
|
117
|
+
/\bpasswd\b/i,
|
|
118
|
+
/\bchmod\s+-R\s+000\b/i,
|
|
119
|
+
/\b>\s*\/dev\/sd/i,
|
|
120
|
+
/\biptables\s+-F\b/i,
|
|
121
|
+
/\bsystemctl\s+(stop|disable|mask)\b/i,
|
|
122
|
+
/\bdocker\s+(system\s+prune|rmi|rm\s+-f)\b/i,
|
|
123
|
+
/\bpm2\s+(delete|del|kill)\b/i
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
function isDestructiveCommand(cmd) {
|
|
127
|
+
if (!cmd || typeof cmd !== 'string') return false;
|
|
128
|
+
return DESTRUCTIVE_PATTERNS.some(re => re.test(cmd));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Refuse l'opération si MCP_READONLY=true.
|
|
133
|
+
* @throws Error
|
|
134
|
+
*/
|
|
135
|
+
function assertWritable(actionLabel = 'cette opération') {
|
|
136
|
+
// Lazy import-free: caller passes config.readOnly or we read env
|
|
137
|
+
const ro = process.env.MCP_READONLY === 'true' || process.env.MCP_READONLY === '1';
|
|
138
|
+
if (ro) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Mode lecture seule (MCP_READONLY=1) : ${actionLabel} est refusé. ` +
|
|
141
|
+
`Relancez le MCP sans MCP_READONLY pour écrire.`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compacte un objet pour l'agent (tronque gros champs).
|
|
148
|
+
* options = { maxString=500, maxArray=30, dropKeys=[] }
|
|
149
|
+
*/
|
|
150
|
+
function compactResult(obj, options = {}) {
|
|
151
|
+
const maxString = options.maxString ?? 500;
|
|
152
|
+
const maxArray = options.maxArray ?? 30;
|
|
153
|
+
const dropKeys = new Set(options.dropKeys || ['diff', 'raw_output', 'content']);
|
|
154
|
+
|
|
155
|
+
function walk(v, key) {
|
|
156
|
+
if (v == null) return v;
|
|
157
|
+
if (typeof v === 'string') {
|
|
158
|
+
if (dropKeys.has(key)) return v.length > 80 ? `[omitted ${v.length} chars]` : v;
|
|
159
|
+
return v.length > maxString ? v.slice(0, maxString) + `…[+${v.length - maxString}]` : v;
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(v)) {
|
|
162
|
+
const sliced = v.slice(0, maxArray).map((item) => walk(item, null));
|
|
163
|
+
if (v.length > maxArray) sliced.push(`…+${v.length - maxArray} items`);
|
|
164
|
+
return sliced;
|
|
165
|
+
}
|
|
166
|
+
if (typeof v === 'object') {
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const [k, val] of Object.entries(v)) {
|
|
169
|
+
if (dropKeys.has(k) && typeof val === 'string' && val.length > 100) {
|
|
170
|
+
out[k] = `[omitted ${val.length} chars]`;
|
|
171
|
+
} else {
|
|
172
|
+
out[k] = walk(val, k);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
return v;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return walk(obj, null);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function wantsCompact(params = {}) {
|
|
184
|
+
if (params && params.compact === true) return true;
|
|
185
|
+
if (params && params.compact === false) return false;
|
|
186
|
+
return process.env.MCP_COMPACT === 'true' || process.env.MCP_COMPACT === '1';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export {
|
|
190
|
+
escapeShellArg,
|
|
191
|
+
maskSecret,
|
|
192
|
+
redactSensitiveObject,
|
|
193
|
+
toWaitTimeoutMs,
|
|
194
|
+
isTerminalJobStatus,
|
|
195
|
+
resolveSshPort,
|
|
196
|
+
isDestructiveCommand,
|
|
197
|
+
assertWritable,
|
|
198
|
+
compactResult,
|
|
199
|
+
wantsCompact,
|
|
200
|
+
DESTRUCTIVE_PATTERNS,
|
|
201
|
+
SENSITIVE_KEYS
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export default {
|
|
205
|
+
escapeShellArg,
|
|
206
|
+
maskSecret,
|
|
207
|
+
redactSensitiveObject,
|
|
208
|
+
toWaitTimeoutMs,
|
|
209
|
+
isTerminalJobStatus,
|
|
210
|
+
resolveSshPort,
|
|
211
|
+
isDestructiveCommand,
|
|
212
|
+
assertWritable,
|
|
213
|
+
compactResult,
|
|
214
|
+
wantsCompact
|
|
215
|
+
};
|
package/workSession.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workSession.js — Sessions de travail (journal d'intervention).
|
|
3
|
+
* Stockage atomique: <dataDir>/work_sessions.json
|
|
4
|
+
*/
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import config from './config.js';
|
|
8
|
+
import notes from './notes.js';
|
|
9
|
+
import snapshotManager from './snapshotManager.js';
|
|
10
|
+
import jsonStore from './atomicJsonStore.js';
|
|
11
|
+
|
|
12
|
+
const SESSIONS_PATH = path.join(config.dataDir, 'work_sessions.json');
|
|
13
|
+
const EMPTY = { active: {}, history: [] };
|
|
14
|
+
|
|
15
|
+
const load = () => jsonStore.readJson(SESSIONS_PATH, EMPTY);
|
|
16
|
+
|
|
17
|
+
function trimHistory(data) {
|
|
18
|
+
data.active ||= {};
|
|
19
|
+
data.history ||= [];
|
|
20
|
+
if (data.history.length > 100) data.history = data.history.slice(-100);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
async start({ alias = null, project = null, tag = null, message = null, snapshot = false, paths = [] } = {}) {
|
|
26
|
+
const id = `ws_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
|
27
|
+
const session = {
|
|
28
|
+
id,
|
|
29
|
+
alias,
|
|
30
|
+
project,
|
|
31
|
+
tag: tag || `work-${new Date().toISOString().slice(0, 10)}`,
|
|
32
|
+
message: message || null,
|
|
33
|
+
startedAt: new Date().toISOString(),
|
|
34
|
+
events: [],
|
|
35
|
+
snapshotId: null,
|
|
36
|
+
status: 'active'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Effet externe avant publication de la session. S'il échoue, on garde
|
|
40
|
+
// l'information dans les events mais on ne laisse aucun état partiel.
|
|
41
|
+
if (snapshot && alias && paths.length) {
|
|
42
|
+
try {
|
|
43
|
+
const snap = await snapshotManager.createSnapshot(
|
|
44
|
+
{ type: 'remote', alias }, paths,
|
|
45
|
+
{ tag: session.tag, message: message || `work_start ${id}` }
|
|
46
|
+
);
|
|
47
|
+
session.snapshotId = snap.snapshotId;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
session.events.push({ at: new Date().toISOString(), type: 'snapshot_error', detail: e.message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
54
|
+
trimHistory(data);
|
|
55
|
+
data.active[id] = session;
|
|
56
|
+
return data;
|
|
57
|
+
});
|
|
58
|
+
return session;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async log(id, event) {
|
|
62
|
+
let count = 0;
|
|
63
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
64
|
+
trimHistory(data);
|
|
65
|
+
const session = data.active[id];
|
|
66
|
+
if (!session) throw new Error(`Session '${id}' introuvable ou déjà fermée.`);
|
|
67
|
+
session.events.push({ at: new Date().toISOString(), ...event });
|
|
68
|
+
count = session.events.length;
|
|
69
|
+
return data;
|
|
70
|
+
});
|
|
71
|
+
return { id, events: count };
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async get(id) {
|
|
75
|
+
const data = trimHistory(await load());
|
|
76
|
+
if (data.active[id]) return data.active[id];
|
|
77
|
+
const hist = data.history.find(s => s.id === id);
|
|
78
|
+
if (hist) return hist;
|
|
79
|
+
throw new Error(`Session '${id}' introuvable.`);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async list({ includeHistory = false } = {}) {
|
|
83
|
+
const data = trimHistory(await load());
|
|
84
|
+
const active = Object.values(data.active);
|
|
85
|
+
if (!includeHistory) return { active, historyCount: data.history.length };
|
|
86
|
+
return { active, history: data.history };
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async end(id, { summary = null, note = true } = {}) {
|
|
90
|
+
let closed;
|
|
91
|
+
// Commit principal d'abord : une panne de notes ne doit jamais laisser
|
|
92
|
+
// la session active alors que l'appel end a été accepté.
|
|
93
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
94
|
+
trimHistory(data);
|
|
95
|
+
const session = data.active[id];
|
|
96
|
+
if (!session) throw new Error(`Session '${id}' introuvable ou déjà fermée.`);
|
|
97
|
+
|
|
98
|
+
session.status = 'closed';
|
|
99
|
+
session.endedAt = new Date().toISOString();
|
|
100
|
+
session.summary = summary || session.message || `Session ${id} fermée`;
|
|
101
|
+
session.events.push({ at: session.endedAt, type: 'end', detail: session.summary });
|
|
102
|
+
if (note && session.alias) session.noteUpdated = null; // pending
|
|
103
|
+
|
|
104
|
+
delete data.active[id];
|
|
105
|
+
data.history.push(session);
|
|
106
|
+
trimHistory(data);
|
|
107
|
+
closed = structuredClone(session);
|
|
108
|
+
return data;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (note && closed.alias) {
|
|
112
|
+
let noteUpdated = true;
|
|
113
|
+
let noteError = null;
|
|
114
|
+
try {
|
|
115
|
+
await notes.set(closed.alias, {
|
|
116
|
+
intervention: `[${closed.tag}] ${closed.summary} (${closed.events.length} events)`
|
|
117
|
+
});
|
|
118
|
+
} catch (e) {
|
|
119
|
+
noteUpdated = false;
|
|
120
|
+
noteError = e.message;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Compensation: enrichit l'historique sans rouvrir la session.
|
|
124
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
125
|
+
trimHistory(data);
|
|
126
|
+
const hist = data.history.find(s => s.id === id);
|
|
127
|
+
if (hist) {
|
|
128
|
+
hist.noteUpdated = noteUpdated;
|
|
129
|
+
if (noteError) hist.noteError = noteError;
|
|
130
|
+
else delete hist.noteError;
|
|
131
|
+
closed = structuredClone(hist);
|
|
132
|
+
}
|
|
133
|
+
return data;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return closed;
|
|
138
|
+
}
|
|
139
|
+
};
|
package/.gencodedoc.yaml
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
project_name: sftp-mcp
|
|
2
|
-
ignore:
|
|
3
|
-
dirs:
|
|
4
|
-
- node_modules
|
|
5
|
-
- venv
|
|
6
|
-
- .venv
|
|
7
|
-
- env
|
|
8
|
-
- __pycache__
|
|
9
|
-
- .git
|
|
10
|
-
- dist
|
|
11
|
-
- build
|
|
12
|
-
- .next
|
|
13
|
-
- coverage
|
|
14
|
-
- node_modules
|
|
15
|
-
- dist
|
|
16
|
-
- .next
|
|
17
|
-
- venv
|
|
18
|
-
- .venv
|
|
19
|
-
- __pycache__
|
|
20
|
-
- dist
|
|
21
|
-
- build
|
|
22
|
-
files:
|
|
23
|
-
- .DS_Store
|
|
24
|
-
- Thumbs.db
|
|
25
|
-
- package-lock.json
|
|
26
|
-
- yarn.lock
|
|
27
|
-
extensions:
|
|
28
|
-
- .log
|
|
29
|
-
- .pyc
|
|
30
|
-
- .pyo
|
|
31
|
-
- .exe
|
|
32
|
-
- .bin
|
|
33
|
-
- .jpg
|
|
34
|
-
- .png
|
|
35
|
-
- .gif
|
|
36
|
-
- .mp4
|
|
37
|
-
- .pdf
|
|
38
|
-
- .zip
|
|
39
|
-
- .pyc
|
|
40
|
-
- .pyo
|
|
41
|
-
- .pyd
|
|
42
|
-
patterns: []
|
|
43
|
-
autosave:
|
|
44
|
-
enabled: false
|
|
45
|
-
mode: hybrid
|
|
46
|
-
timer:
|
|
47
|
-
interval: 300
|
|
48
|
-
diff_threshold:
|
|
49
|
-
threshold: 0.05
|
|
50
|
-
check_interval: 60
|
|
51
|
-
ignore_whitespace: true
|
|
52
|
-
ignore_comments: false
|
|
53
|
-
hybrid:
|
|
54
|
-
min_interval: 180
|
|
55
|
-
max_interval: 600
|
|
56
|
-
threshold: 0.03
|
|
57
|
-
retention:
|
|
58
|
-
max_autosaves: 50
|
|
59
|
-
compress_after_days: 7
|
|
60
|
-
delete_after_days: 30
|
|
61
|
-
keep_manual: true
|
|
62
|
-
diff_format:
|
|
63
|
-
default: unified
|
|
64
|
-
unified_context: 3
|
|
65
|
-
json_include_content: true
|
|
66
|
-
ast_enabled: false
|
|
67
|
-
output:
|
|
68
|
-
default_name: '{project}_doc_{date}.md'
|
|
69
|
-
include_tree: true
|
|
70
|
-
include_code: true
|
|
71
|
-
tree_full_code_select: false
|
|
72
|
-
language_detection: true
|
|
73
|
-
max_file_size: 1000000
|
|
74
|
-
storage_path: .gencodedoc
|
|
75
|
-
compression_enabled: true
|
|
76
|
-
compression_level: 3
|
package/ROADMAP.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Roadmap MCP Orchestrator
|
|
2
|
-
|
|
3
|
-
## v11.2.0 (actuelle)
|
|
4
|
-
- ✅ Command Blocklist (policies.js) — sécurité des commandes
|
|
5
|
-
- ✅ Multi-host execution (alias array / "all")
|
|
6
|
-
- ✅ tmux sessions persistantes (create/exec/read/list/kill)
|
|
7
|
-
- ✅ Tunnels SSH (local/remote/SOCKS5) avec allowlist
|
|
8
|
-
- ✅ Guide IA intégré (audit, security, cheatsheet)
|
|
9
|
-
- ✅ Help avec schémas JSON des paramètres
|
|
10
|
-
- ✅ Snapshots versionnés avec déduplication
|
|
11
|
-
- ✅ File edit chirurgical avec protection hash
|
|
12
|
-
- ✅ Diff multi-serveur (compare_all_sources)
|
|
13
|
-
- ✅ get_services_status graceful fallback
|
|
14
|
-
|
|
15
|
-
## v12.x (planifiée)
|
|
16
|
-
- [ ] Tunnels auto-setup de clés entre serveurs
|
|
17
|
-
- [ ] Webhook on tunnel up/down (n8n compatible)
|
|
18
|
-
- [ ] Dashboard web statique pour l'état du parc
|
|
19
|
-
- [ ] Mode "agent" : exécution planifiée (cron-like)
|
|
20
|
-
- [ ] Intégration Vaultwarden pour les secrets
|
|
21
|
-
- [ ] Tests automatisés CI/CD
|