@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/diagnose.js ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+
3
+ console.error("🔍 Diagnostic MCP Orchestrator\n");
4
+
5
+ // 1. Tester les imports
6
+ console.error("1️⃣ Test des imports...");
7
+ try {
8
+ await import("@modelcontextprotocol/sdk/server/mcp.js");
9
+ console.error(" ✅ @modelcontextprotocol/sdk");
10
+ } catch (e) {
11
+ console.error(" ❌ @modelcontextprotocol/sdk:", e.message);
12
+ process.exit(1);
13
+ }
14
+
15
+ try {
16
+ await import("zod");
17
+ console.error(" ✅ zod");
18
+ } catch (e) {
19
+ console.error(" ❌ zod:", e.message);
20
+ process.exit(1);
21
+ }
22
+
23
+ // 2. Tester config
24
+ console.error("\n2️⃣ Test de config...");
25
+ try {
26
+ const config = await import("./config.js");
27
+ console.error(" ✅ config.dataDir:", config.default.dataDir);
28
+ console.error(" ✅ config.syncTimeout:", config.default.syncTimeout);
29
+ } catch (e) {
30
+ console.error(" ❌ config:", e.message);
31
+ process.exit(1);
32
+ }
33
+
34
+ // 3. Tester queue
35
+ console.error("\n3️⃣ Test de queue...");
36
+ try {
37
+ const queue = await import("./queue.js");
38
+ console.error(" ✅ queue importée");
39
+ const stats = queue.default.getStats();
40
+ console.error(" ✅ queue.getStats():", JSON.stringify(stats));
41
+ } catch (e) {
42
+ console.error(" ❌ queue:", e.message);
43
+ console.error(e.stack);
44
+ process.exit(1);
45
+ }
46
+
47
+ // 4. Tester servers
48
+ console.error("\n4️⃣ Test de servers...");
49
+ try {
50
+ const servers = await import("./servers.js");
51
+ const list = await servers.default.listServers();
52
+ console.error(" ✅ servers.listServers():", Object.keys(list).length, "serveurs");
53
+ } catch (e) {
54
+ console.error(" ❌ servers:", e.message);
55
+ process.exit(1);
56
+ }
57
+
58
+ // 5. Tester un simple serveur MCP
59
+ console.error("\n5️⃣ Test d'un serveur MCP minimal...");
60
+ try {
61
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
62
+ const { z } = await import("zod");
63
+
64
+ const server = new McpServer({
65
+ name: "test",
66
+ version: "1.0.0",
67
+ description: "Test"
68
+ });
69
+
70
+ server.registerTool(
71
+ "test",
72
+ {
73
+ title: "Test",
74
+ description: "Test",
75
+ inputSchema: z.object({})
76
+ },
77
+ async () => ({ content: [{ type: "text", text: "OK" }] })
78
+ );
79
+
80
+ console.error(" ✅ Serveur MCP minimal fonctionne");
81
+ } catch (e) {
82
+ console.error(" ❌ Serveur MCP:", e.message);
83
+ console.error(e.stack);
84
+ process.exit(1);
85
+ }
86
+
87
+ console.error("\n✅ TOUS LES TESTS PASSENT");
88
+ console.error("\n📋 Prochaine étape: vérifier server.js ligne par ligne");
89
+ process.exit(0);
package/diffEngine.js ADDED
@@ -0,0 +1,141 @@
1
+ import { createTwoFilesPatch } from 'diff';
2
+ import sourceAdapter from './sourceAdapter.js';
3
+ import fileOps from './fileOps.js';
4
+
5
+ /**
6
+ * diffEngine — Comparaison fichier à fichier et dossier à dossier.
7
+ * Fonctionne pour n'importe quelle combinaison local/remote via sourceAdapter.
8
+ * Chaque résultat indique clairement l'origine (serveur + chemin) des sources.
9
+ */
10
+
11
+ function countAddRemove(patch) {
12
+ let added = 0, removed = 0;
13
+ for (const line of patch.split('\n')) {
14
+ if (line.startsWith('+') && !line.startsWith('+++')) added++;
15
+ else if (line.startsWith('-') && !line.startsWith('---')) removed++;
16
+ }
17
+ return { added, removed };
18
+ }
19
+
20
+ export default {
21
+ /**
22
+ * Compare deux fichiers. Retourne :
23
+ * { identical, source1, source2, hash1, hash2, diff?, added?, removed? }
24
+ * source1/source2 = { server, path, label } (origine claire).
25
+ */
26
+ async diffFiles(s1, s2) {
27
+ const f1 = await fileOps.readFile(s1);
28
+ const f2 = await fileOps.readFile(s2);
29
+
30
+ const origin1 = sourceAdapter.describe(s1);
31
+ const origin2 = sourceAdapter.describe(s2);
32
+
33
+ if (f1.hash === f2.hash) {
34
+ return {
35
+ identical: true,
36
+ source1: origin1,
37
+ source2: origin2,
38
+ hash: f1.hash,
39
+ stats: { size1: f1.size, size2: f2.size }
40
+ };
41
+ }
42
+
43
+ const patch = createTwoFilesPatch(
44
+ origin1.label, origin2.label,
45
+ f1.content, f2.content,
46
+ 'source1', 'source2'
47
+ );
48
+ const { added, removed } = countAddRemove(patch);
49
+
50
+ return {
51
+ identical: false,
52
+ source1: origin1,
53
+ source2: origin2,
54
+ hash1: f1.hash,
55
+ hash2: f2.hash,
56
+ added,
57
+ removed,
58
+ diff: patch,
59
+ stats: { size1: f1.size, size2: f2.size }
60
+ };
61
+ },
62
+
63
+ /**
64
+ * Compare deux dossiers (arborescences). Retourne :
65
+ * { source1, source2, only_in_source1[], only_in_source2[],
66
+ * identical[], modified[{path, hash1, hash2, added, removed, diff?}], stats }
67
+ * options = { recursive, compareContent, ignorePatterns, includeDiff }
68
+ * - compareContent (défaut true) : compare le contenu des fichiers communs par hash
69
+ * - includeDiff (défaut false) : inclut le diff unifié pour chaque fichier modifié
70
+ */
71
+ async diffFolders(s1, s2, options = {}) {
72
+ const recursive = options.recursive !== false;
73
+ const compareContent = options.compareContent !== false;
74
+ const includeDiff = options.includeDiff === true;
75
+ const ignorePatterns = options.ignorePatterns || [];
76
+
77
+ const origin1 = sourceAdapter.describe(s1);
78
+ const origin2 = sourceAdapter.describe(s2);
79
+
80
+ const list1 = await sourceAdapter.listFilesRecursive(s1, { recursive, ignorePatterns });
81
+ const list2 = await sourceAdapter.listFilesRecursive(s2, { recursive, ignorePatterns });
82
+
83
+ const set2 = new Set(list2);
84
+ const set1 = new Set(list1);
85
+
86
+ const only_in_source1 = list1.filter(p => !set2.has(p));
87
+ const only_in_source2 = list2.filter(p => !set1.has(p));
88
+ const common = list1.filter(p => set2.has(p));
89
+
90
+ const identical = [];
91
+ const modified = [];
92
+
93
+ if (compareContent && common.length > 0) {
94
+ // Lit les fichiers communs des deux côtés (batch par source pour perf remote)
95
+ const hashes1 = await sourceAdapter.readManyHashes(s1, s1.path, common);
96
+ const hashes2 = await sourceAdapter.readManyHashes(s2, s2.path, common);
97
+
98
+ for (const rel of common) {
99
+ const h1 = hashes1.get(rel);
100
+ const h2 = hashes2.get(rel);
101
+ if (h1.hash === h2.hash) {
102
+ identical.push(rel);
103
+ } else {
104
+ const entry = { path: rel, hash1: h1.hash, hash2: h2.hash };
105
+ // diff textuel (best-effort ; ignore si binaire)
106
+ try {
107
+ const patch = createTwoFilesPatch(
108
+ `${origin1.label}/${rel}`, `${origin2.label}/${rel}`,
109
+ h1.content.toString('utf8'), h2.content.toString('utf8'),
110
+ 'source1', 'source2'
111
+ );
112
+ const { added, removed } = countAddRemove(patch);
113
+ entry.added = added;
114
+ entry.removed = removed;
115
+ if (includeDiff) entry.diff = patch;
116
+ } catch (e) {
117
+ entry.note = 'diff indisponible (binaire ?)';
118
+ }
119
+ modified.push(entry);
120
+ }
121
+ }
122
+ }
123
+
124
+ return {
125
+ source1: origin1,
126
+ source2: origin2,
127
+ only_in_source1,
128
+ only_in_source2,
129
+ identical,
130
+ modified,
131
+ stats: {
132
+ total1: list1.length,
133
+ total2: list2.length,
134
+ onlyIn1: only_in_source1.length,
135
+ onlyIn2: only_in_source2.length,
136
+ identical: identical.length,
137
+ modified: modified.length
138
+ }
139
+ };
140
+ }
141
+ };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * diffFormatter — Rendu lisible des diffs pour les clients MCP.
3
+ *
4
+ * Les outils renvoient des métadonnées JSON (hash, applied, added/removed...)
5
+ * dont l'IA a besoin de façon fiable. Mais un diff unifié brut dans un champ
6
+ * JSON est illisible (\n échappés). Ce module produit :
7
+ * - un bloc markdown ```diff (coloré dans les clients, "comme les vrais tools")
8
+ * - un résumé concis (+X / -Y lignes)
9
+ *
10
+ * Convention de sortie des outils : content = [ blocMarkdown, blocJSON ].
11
+ */
12
+
13
+ // Nettoie un patch unifié (createTwoFilesPatch) : retire le bruit d'en-tête
14
+ // (Index:/===) tout en gardant ---/+++/@@ et le corps.
15
+ function cleanPatch(patch) {
16
+ return patch
17
+ .split('\n')
18
+ .filter(line => !line.startsWith('Index: ') && !/^=+$/.test(line))
19
+ .join('\n')
20
+ .trim();
21
+ }
22
+
23
+ // Compte les lignes ajoutées/supprimées (hors en-têtes +++/---)
24
+ function countAddRemove(patch) {
25
+ let added = 0, removed = 0;
26
+ for (const line of patch.split('\n')) {
27
+ if (line.startsWith('+') && !line.startsWith('+++')) added++;
28
+ else if (line.startsWith('-') && !line.startsWith('---')) removed++;
29
+ }
30
+ return { added, removed };
31
+ }
32
+
33
+ // Barre visuelle proportionnelle (ex: +++++-- )
34
+ function bar(added, removed, width = 20) {
35
+ const total = added + removed;
36
+ if (total === 0) return '';
37
+ const plus = Math.round((added / total) * width);
38
+ const minus = width - plus;
39
+ return '+'.repeat(plus) + '-'.repeat(minus);
40
+ }
41
+
42
+ export default {
43
+ countAddRemove,
44
+
45
+ /**
46
+ * Formate un patch unifié en markdown.
47
+ * opts = { title }
48
+ * Retourne { markdown, summary, added, removed }.
49
+ */
50
+ format(patch, opts = {}) {
51
+ const cleaned = cleanPatch(patch);
52
+ const { added, removed } = countAddRemove(patch);
53
+ const summary = `+${added} / -${removed} ligne${(added + removed) > 1 ? 's' : ''}`;
54
+
55
+ let markdown = '';
56
+ if (opts.title) markdown += `### ${opts.title}\n`;
57
+ markdown += `**${summary}** \`${bar(added, removed)}\`\n\n`;
58
+ markdown += '```diff\n' + cleaned + '\n```';
59
+
60
+ return { markdown, summary, added, removed };
61
+ },
62
+
63
+ /**
64
+ * Construit une réponse MCP à deux blocs : markdown lisible + JSON metadata.
65
+ * - markdown : string déjà formatée (via format())
66
+ * - meta : objet metadata (sera JSON.stringify)
67
+ * Le champ 'diff' brut est retiré du JSON (déjà rendu en markdown) pour
68
+ * éviter la duplication et économiser des tokens.
69
+ */
70
+ response(markdown, meta) {
71
+ const clean = { ...meta };
72
+ delete clean.diff; // le diff est dans le bloc markdown
73
+ return {
74
+ content: [
75
+ { type: "text", text: markdown },
76
+ { type: "text", text: JSON.stringify(clean, null, 2) }
77
+ ]
78
+ };
79
+ },
80
+
81
+ /**
82
+ * Résumé d'un diff de dossiers/snapshots (listes de fichiers) en markdown.
83
+ * groups = { added:[], removed:[], modified:[{path, added, removed}], identical? }
84
+ */
85
+ formatFileList(groups, opts = {}) {
86
+ const lines = [];
87
+ if (opts.title) lines.push(`### ${opts.title}`);
88
+ const a = groups.added?.length || 0;
89
+ const r = groups.removed?.length || 0;
90
+ const m = groups.modified?.length || 0;
91
+ lines.push(`**${m} modifié(s), ${a} ajouté(s), ${r} supprimé(s)**\n`);
92
+
93
+ if (m > 0) {
94
+ lines.push('**Modifiés :**');
95
+ for (const f of groups.modified) {
96
+ const p = f.path || f;
97
+ const delta = (f.added !== undefined) ? ` (+${f.added}/-${f.removed})` : '';
98
+ lines.push(`- \`${p}\`${delta}`);
99
+ }
100
+ lines.push('');
101
+ }
102
+ if (a > 0) {
103
+ lines.push('**Ajoutés :**');
104
+ for (const p of groups.added) lines.push(`- \`${p}\``);
105
+ lines.push('');
106
+ }
107
+ if (r > 0) {
108
+ lines.push('**Supprimés :**');
109
+ for (const p of groups.removed) lines.push(`- \`${p}\``);
110
+ lines.push('');
111
+ }
112
+ return lines.join('\n').trim();
113
+ }
114
+ };
package/fileOps.js ADDED
@@ -0,0 +1,275 @@
1
+ import crypto from 'crypto';
2
+ import pathModule from 'path';
3
+ import { createTwoFilesPatch } from 'diff';
4
+ import sourceAdapter from './sourceAdapter.js';
5
+ import config from './config.js';
6
+
7
+ /**
8
+ * fileOps — Opérations fichiers de haut niveau (local ET remote via sourceAdapter).
9
+ *
10
+ * Ajoute par-dessus sourceAdapter :
11
+ * - calcul de hash SHA-256 (protection contre modifications concurrentes)
12
+ * - génération de diff unifié
13
+ * - édition sécurisée (read → vérif hash → diff → write)
14
+ *
15
+ * Gère texte (utf8) et binaire (base64).
16
+ */
17
+
18
+ function sha256(buffer) {
19
+ return crypto.createHash('sha256').update(buffer).digest('hex');
20
+ }
21
+
22
+ // E — Détection binaire (heuristique git) : un octet nul dans les 8000 premiers
23
+ // octets = contenu binaire. Rapide et fiable pour éviter la corruption utf8.
24
+ function looksBinary(buffer) {
25
+ const len = Math.min(buffer.length, 8000);
26
+ for (let i = 0; i < len; i++) {
27
+ if (buffer[i] === 0) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function resolvePath(sourcePath) {
33
+ // Nettoie les traversées de répertoire
34
+ const normalized = pathModule.normalize(sourcePath).replace(/\/$/, '');
35
+ return normalized;
36
+ }
37
+
38
+ function validatePath(sourcePath) {
39
+ if (!config.allowedRoots || config.allowedRoots.length === 0) {
40
+ return; // Aucune restriction configurée
41
+ }
42
+
43
+ const resolved = resolvePath(sourcePath);
44
+ const allowed = config.allowedRoots.some(root => {
45
+ const normalRoot = pathModule.normalize(root).replace(/\/$/, '');
46
+ return resolved === normalRoot || resolved.startsWith(normalRoot + '/');
47
+ });
48
+
49
+ if (!allowed) {
50
+ const roots = config.allowedRoots.join(', ');
51
+ throw new Error(`Accès refusé : '${resolved}' n'est pas dans les racines autorisées. Ajoutez le chemin à MCP_ALLOWED_ROOTS ou modifiez allowedRoots. Racines: ${roots}`);
52
+ }
53
+ }
54
+
55
+ export default {
56
+ /**
57
+ * Lit un fichier et retourne { content, hash, mtime, size, encoding, ... }.
58
+ * encoding 'utf8' → content est une string ; 'base64' → content encodé base64.
59
+ *
60
+ * E — Détection binaire auto : si encoding='utf8' mais le contenu est binaire
61
+ * (octet nul détecté), bascule automatiquement en base64 et signale via
62
+ * binaryDetected:true (évite la corruption silencieuse d'un .png/.gz lu en utf8).
63
+ * Désactivable avec options.autoDetect=false.
64
+ *
65
+ * options = { offset, limit, autoDetect } :
66
+ * - offset : ligne de départ (1-indexée) — lecture partielle PAR LIGNES (utf8)
67
+ * - limit : nombre de lignes à retourner
68
+ * Mode consultation pour gros fichiers. Le HASH reste TOUJOURS celui du
69
+ * fichier COMPLET, pour que file_edit fonctionne (il relit le fichier entier).
70
+ */
71
+ async readFile(source, encoding = 'utf8', options = {}) {
72
+ if (source.path) validatePath(source.path);
73
+ const { content, mtime, size } = await sourceAdapter.readFile(source);
74
+ // content est un Buffer ; le hash est TOUJOURS calculé sur le fichier complet
75
+ const hash = sha256(content);
76
+ // origin : d'où vient le fichier (serveur + chemin) — évite la confusion
77
+ const origin = sourceAdapter.describe(source);
78
+
79
+ // E — bascule auto utf8 → base64 si contenu binaire détecté
80
+ const autoDetect = options.autoDetect !== false;
81
+ let binaryDetected = false;
82
+ let effectiveEncoding = encoding;
83
+ if (encoding === 'utf8' && autoDetect && looksBinary(content)) {
84
+ effectiveEncoding = 'base64';
85
+ binaryDetected = true;
86
+ }
87
+
88
+ if (effectiveEncoding === 'base64') {
89
+ // offset/limit n'ont pas de sens sur du binaire
90
+ return {
91
+ origin, content: content.toString('base64'), hash, mtime, size,
92
+ encoding: 'base64',
93
+ ...(binaryDetected ? { binaryDetected: true, note: "Contenu binaire détecté (octet nul) — retourné en base64 automatiquement." } : {})
94
+ };
95
+ }
96
+
97
+ const full = content.toString('utf8');
98
+ const { offset, limit } = options;
99
+
100
+ // Lecture complète (défaut) → edit-safe
101
+ if (offset === undefined && limit === undefined) {
102
+ return { origin, content: full, hash, mtime, size, encoding: 'utf8' };
103
+ }
104
+
105
+ // Lecture partielle par lignes (mode consultation)
106
+ const lines = full.split('\n');
107
+ const totalLines = lines.length;
108
+ const start = offset && offset > 0 ? offset - 1 : 0; // offset 1-indexé
109
+ const end = limit ? start + limit : totalLines;
110
+ const slice = lines.slice(start, end).join('\n');
111
+
112
+ return {
113
+ origin,
114
+ content: slice,
115
+ hash, // hash du fichier COMPLET (protection file_edit)
116
+ mtime,
117
+ size,
118
+ encoding,
119
+ isPartial: true,
120
+ offset: start + 1,
121
+ limit: limit ?? (totalLines - start),
122
+ totalLines,
123
+ returnedLines: Math.min(end, totalLines) - start
124
+ };
125
+ },
126
+
127
+ /**
128
+ * Écrit un fichier. content est une string (utf8 ou base64 selon encoding).
129
+ * options = { createDirs }
130
+ * Retourne { hash, size }.
131
+ */
132
+ async writeFile(source, content, encoding = 'utf8', options = {}) {
133
+ if (source.path) validatePath(source.path);
134
+ const buffer = encoding === 'base64'
135
+ ? Buffer.from(content, 'base64')
136
+ : Buffer.from(content, 'utf8');
137
+ const newHash = sha256(buffer);
138
+ const origin = sourceAdapter.describe(source);
139
+
140
+ // B — dryRun : montre ce qui serait écrit (diff vs existant) sans écrire
141
+ if (options.dryRun) {
142
+ let existed = false;
143
+ let oldContent = '';
144
+ try {
145
+ const cur = await sourceAdapter.readFile(source);
146
+ existed = true;
147
+ oldContent = cur.content.toString('utf8');
148
+ } catch { /* fichier inexistant → création */ }
149
+ const patch = encoding === 'base64'
150
+ ? '(binaire — diff non affiché)'
151
+ : createTwoFilesPatch(origin.label, origin.label, oldContent, content, existed ? 'avant' : '(nouveau)', 'après');
152
+ return { origin, dryRun: true, wouldWrite: true, existed, hash: newHash, size: buffer.length, diff: patch };
153
+ }
154
+
155
+ // C — backup auto avant écriture
156
+ let backup = null;
157
+ if (options.backup) backup = await this._backup(source, options.backupMessage);
158
+
159
+ const result = await sourceAdapter.writeFile(source, buffer, options);
160
+ return { origin, hash: newHash, size: result.size, ...(backup ? { backup } : {}) };
161
+ },
162
+
163
+ /**
164
+ * C — Crée un snapshot du fichier avant modification (filet de sécurité).
165
+ * Import dynamique de snapshotManager pour éviter tout couplage au chargement.
166
+ * Retourne { snapshotId, tag } ou null si le fichier n'existe pas encore.
167
+ */
168
+ async _backup(source, message) {
169
+ const exists = await sourceAdapter.exists(source);
170
+ if (!exists) return null; // rien à sauvegarder (nouveau fichier)
171
+ const { default: snapshotManager } = await import('./snapshotManager.js');
172
+ const snapSource = source.type === 'local'
173
+ ? { type: 'local' }
174
+ : { type: 'remote', alias: source.alias };
175
+ const tag = `auto-backup-${Date.now()}`;
176
+ const res = await snapshotManager.createSnapshot(snapSource, [source.path], {
177
+ tag,
178
+ message: message || `Backup auto avant modification de ${source.path}`
179
+ });
180
+ return { snapshotId: res.snapshotId, tag };
181
+ },
182
+
183
+ /**
184
+ * Édite un fichier avec protection par hash. DEUX MODES :
185
+ *
186
+ * A) Chirurgical : { oldString, newString, replaceAll? }
187
+ * Remplace un bout précis. Sûr : erreur si oldString absent (OLDSTRING_NOT_FOUND)
188
+ * ou présent plusieurs fois sans replaceAll (MULTIPLE_MATCHES).
189
+ * Économe en tokens (pas besoin de renvoyer tout le fichier).
190
+ *
191
+ * B) Complet : { newContent } → remplace tout le contenu.
192
+ *
193
+ * Options communes : { expectedHash, encoding, dryRun, backup, backupMessage }
194
+ * - expectedHash : protège contre modification concurrente (HASH_MISMATCH)
195
+ * - dryRun : retourne le diff sans écrire
196
+ * - backup : snapshot du fichier avant écriture (undo via snapshot_restore)
197
+ *
198
+ * Retourne { origin, hash, diff, applied, added, removed, size, backup? }.
199
+ */
200
+ async editFile(source, params = {}, legacyExpectedHash, legacyEncoding) {
201
+ // Rétrocompat : ancienne signature editFile(source, newContent, expectedHash, encoding)
202
+ if (typeof params === 'string') {
203
+ params = { newContent: params, expectedHash: legacyExpectedHash, encoding: legacyEncoding };
204
+ }
205
+ const encoding = params.encoding || 'utf8';
206
+ const current = await this.readFile(source, encoding);
207
+
208
+ // Protection hash (optionnelle mais recommandée)
209
+ if (params.expectedHash && current.hash !== params.expectedHash) {
210
+ const err = new Error(
211
+ `Le fichier a été modifié depuis la dernière lecture (hash attendu: ${params.expectedHash}, actuel: ${current.hash}).`
212
+ );
213
+ err.code = 'HASH_MISMATCH';
214
+ err.current = current;
215
+ throw err;
216
+ }
217
+
218
+ // Détermine le nouveau contenu selon le mode
219
+ let newContent;
220
+ if (params.oldString !== undefined) {
221
+ // Mode A — chirurgical
222
+ const occurrences = current.content.split(params.oldString).length - 1;
223
+ if (occurrences === 0) {
224
+ const err = new Error(`oldString introuvable dans le fichier. Vérifiez le texte exact (espaces/indentation inclus).`);
225
+ err.code = 'OLDSTRING_NOT_FOUND';
226
+ throw err;
227
+ }
228
+ if (occurrences > 1 && !params.replaceAll) {
229
+ const err = new Error(`oldString présent ${occurrences} fois. Précisez plus de contexte pour un match unique, ou utilisez replaceAll:true.`);
230
+ err.code = 'MULTIPLE_MATCHES';
231
+ err.occurrences = occurrences;
232
+ throw err;
233
+ }
234
+ newContent = params.replaceAll
235
+ ? current.content.split(params.oldString).join(params.newString ?? '')
236
+ : current.content.replace(params.oldString, params.newString ?? '');
237
+ } else if (params.newContent !== undefined) {
238
+ // Mode B — remplacement complet
239
+ newContent = params.newContent;
240
+ } else {
241
+ throw new Error("Aucune modification fournie : utilisez { oldString, newString } (chirurgical) ou { newContent } (complet).");
242
+ }
243
+
244
+ // Diff lisible
245
+ const origin = sourceAdapter.describe(source);
246
+ const patch = createTwoFilesPatch(origin.label, origin.label, current.content, newContent, 'avant', 'après');
247
+ let added = 0, removed = 0;
248
+ for (const line of patch.split('\n')) {
249
+ if (line.startsWith('+') && !line.startsWith('+++')) added++;
250
+ else if (line.startsWith('-') && !line.startsWith('---')) removed++;
251
+ }
252
+
253
+ // B — dryRun : preview sans écrire
254
+ if (params.dryRun) {
255
+ return { origin, dryRun: true, applied: false, diff: patch, added, removed };
256
+ }
257
+
258
+ // C — backup avant écriture
259
+ let backup = null;
260
+ if (params.backup) backup = await this._backup(source, params.backupMessage);
261
+
262
+ const writeResult = await this.writeFile(source, newContent, encoding);
263
+
264
+ return {
265
+ origin,
266
+ hash: writeResult.hash,
267
+ diff: patch,
268
+ applied: true,
269
+ added,
270
+ removed,
271
+ size: writeResult.size,
272
+ ...(backup ? { backup } : {})
273
+ };
274
+ }
275
+ };