@imenam/mcp-design 1.0.1
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/README.md +231 -0
- package/dist/shared/node-defaults.d.ts +30 -0
- package/dist/shared/node-defaults.js +67 -0
- package/dist/shared/node-fields.d.ts +33 -0
- package/dist/shared/node-fields.js +40 -0
- package/dist/shared/render.d.ts +105 -0
- package/dist/shared/render.js +215 -0
- package/dist/shared/schemas/design.schema.d.ts +231 -0
- package/dist/shared/schemas/design.schema.js +98 -0
- package/dist/shared/tree.d.ts +93 -0
- package/dist/shared/tree.js +223 -0
- package/dist/src/cli/setup-mcp.d.ts +5 -0
- package/dist/src/cli/setup-mcp.js +87 -0
- package/dist/src/core/design-store.d.ts +61 -0
- package/dist/src/core/design-store.js +294 -0
- package/dist/src/core/diff.d.ts +22 -0
- package/dist/src/core/diff.js +97 -0
- package/dist/src/core/export.d.ts +6 -0
- package/dist/src/core/export.js +251 -0
- package/dist/src/core/hierarchy.d.ts +18 -0
- package/dist/src/core/hierarchy.js +68 -0
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +21 -0
- package/dist/src/core/log-dir.d.ts +6 -0
- package/dist/src/core/log-dir.js +12 -0
- package/dist/src/core/migrate.d.ts +18 -0
- package/dist/src/core/migrate.js +54 -0
- package/dist/src/core/node-factory.d.ts +19 -0
- package/dist/src/core/node-factory.js +103 -0
- package/dist/src/core/screenshot.d.ts +31 -0
- package/dist/src/core/screenshot.js +53 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +334 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +673 -0
- package/dist/src/tools/add-nodes.d.ts +7 -0
- package/dist/src/tools/add-nodes.js +26 -0
- package/dist/src/tools/create-design.d.ts +7 -0
- package/dist/src/tools/create-design.js +15 -0
- package/dist/src/tools/delete-design.d.ts +3 -0
- package/dist/src/tools/delete-design.js +7 -0
- package/dist/src/tools/delete-nodes.d.ts +3 -0
- package/dist/src/tools/delete-nodes.js +29 -0
- package/dist/src/tools/export-design.d.ts +3 -0
- package/dist/src/tools/export-design.js +21 -0
- package/dist/src/tools/get-changes.d.ts +11 -0
- package/dist/src/tools/get-changes.js +27 -0
- package/dist/src/tools/get-design.d.ts +7 -0
- package/dist/src/tools/get-design.js +29 -0
- package/dist/src/tools/group-nodes.d.ts +10 -0
- package/dist/src/tools/group-nodes.js +83 -0
- package/dist/src/tools/list-designs.d.ts +3 -0
- package/dist/src/tools/list-designs.js +13 -0
- package/dist/src/tools/mark-synced.d.ts +7 -0
- package/dist/src/tools/mark-synced.js +19 -0
- package/dist/src/tools/reorder-nodes.d.ts +7 -0
- package/dist/src/tools/reorder-nodes.js +60 -0
- package/dist/src/tools/screenshot-design.d.ts +3 -0
- package/dist/src/tools/screenshot-design.js +36 -0
- package/dist/src/tools/types.d.ts +25 -0
- package/dist/src/tools/types.js +13 -0
- package/dist/src/tools/ungroup-nodes.d.ts +7 -0
- package/dist/src/tools/ungroup-nodes.js +64 -0
- package/dist/src/tools/update-design.d.ts +4 -0
- package/dist/src/tools/update-design.js +33 -0
- package/dist/src/tools/update-nodes.d.ts +3 -0
- package/dist/src/tools/update-nodes.js +35 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +2 -0
- package/package.json +58 -0
- package/scripts/generate-version.js +20 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/** Boîte englobante normalisée (largeur/hauteur toujours positives). */
|
|
2
|
+
export function normalizedBox(node) {
|
|
3
|
+
const x = node.width < 0 ? node.x + node.width : node.x;
|
|
4
|
+
const y = node.height < 0 ? node.y + node.height : node.y;
|
|
5
|
+
return { x, y, width: Math.abs(node.width), height: Math.abs(node.height) };
|
|
6
|
+
}
|
|
7
|
+
/** Enfants directs de chaque parent, dans l'ordre du tableau (donc l'ordre d'empilement). */
|
|
8
|
+
export function childrenByParent(nodes) {
|
|
9
|
+
const map = new Map();
|
|
10
|
+
for (const node of nodes) {
|
|
11
|
+
const siblings = map.get(node.parentId);
|
|
12
|
+
if (siblings)
|
|
13
|
+
siblings.push(node);
|
|
14
|
+
else
|
|
15
|
+
map.set(node.parentId, [node]);
|
|
16
|
+
}
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
export function rootNodes(nodes) {
|
|
20
|
+
return nodes.filter((node) => node.parentId === null);
|
|
21
|
+
}
|
|
22
|
+
/** Chaîne des ancêtres, du parent direct jusqu'à la racine. */
|
|
23
|
+
export function ancestorsOf(nodes, id) {
|
|
24
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
25
|
+
const chain = [];
|
|
26
|
+
let current = byId.get(id)?.parentId ?? null;
|
|
27
|
+
// La borne empêche une boucle infinie si un cycle a échappé à la validation.
|
|
28
|
+
while (current !== null && chain.length <= nodes.length) {
|
|
29
|
+
const parent = byId.get(current);
|
|
30
|
+
if (!parent)
|
|
31
|
+
break;
|
|
32
|
+
chain.push(parent);
|
|
33
|
+
current = parent.parentId;
|
|
34
|
+
}
|
|
35
|
+
return chain;
|
|
36
|
+
}
|
|
37
|
+
/** Ancêtre le plus haut d'un élément, ou l'élément lui-même s'il est à la racine. */
|
|
38
|
+
export function outermostAncestor(nodes, id) {
|
|
39
|
+
const chain = ancestorsOf(nodes, id);
|
|
40
|
+
return chain.length > 0 ? chain[chain.length - 1] : nodes.find((node) => node.id === id);
|
|
41
|
+
}
|
|
42
|
+
/** Identifiants de tous les descendants d'un élément (lui-même exclu). */
|
|
43
|
+
export function descendantIds(nodes, id) {
|
|
44
|
+
const children = childrenByParent(nodes);
|
|
45
|
+
const found = new Set();
|
|
46
|
+
const queue = [id];
|
|
47
|
+
while (queue.length > 0) {
|
|
48
|
+
for (const child of children.get(queue.pop()) ?? []) {
|
|
49
|
+
if (found.has(child.id))
|
|
50
|
+
continue;
|
|
51
|
+
found.add(child.id);
|
|
52
|
+
queue.push(child.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Réécrit la liste à plat en parcours préfixe : chaque parent est suivi de son
|
|
59
|
+
* sous-arbre. L'ordre relatif entre frères — donc l'empilement — est préservé.
|
|
60
|
+
*
|
|
61
|
+
* Le stockage garde ainsi une forme unique pour un même arbre, ce qui évite au
|
|
62
|
+
* diff de signaler un réordonnancement là où rien n'a bougé visuellement.
|
|
63
|
+
*/
|
|
64
|
+
export function flattenPreOrder(nodes) {
|
|
65
|
+
const children = childrenByParent(nodes);
|
|
66
|
+
const emitted = new Set();
|
|
67
|
+
const result = [];
|
|
68
|
+
const visit = (node) => {
|
|
69
|
+
if (emitted.has(node.id))
|
|
70
|
+
return;
|
|
71
|
+
emitted.add(node.id);
|
|
72
|
+
result.push(node);
|
|
73
|
+
for (const child of children.get(node.id) ?? [])
|
|
74
|
+
visit(child);
|
|
75
|
+
};
|
|
76
|
+
for (const node of children.get(null) ?? [])
|
|
77
|
+
visit(node);
|
|
78
|
+
// Filet : un noeud dont le parent a disparu reste dans la liste plutôt que d'être perdu.
|
|
79
|
+
for (const node of nodes)
|
|
80
|
+
visit(node);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
const ORIGIN = { x: 0, y: 0 };
|
|
84
|
+
function unionBox(boxes) {
|
|
85
|
+
if (boxes.length === 0)
|
|
86
|
+
return null;
|
|
87
|
+
let minX = Infinity;
|
|
88
|
+
let minY = Infinity;
|
|
89
|
+
let maxX = -Infinity;
|
|
90
|
+
let maxY = -Infinity;
|
|
91
|
+
for (const box of boxes) {
|
|
92
|
+
minX = Math.min(minX, box.x);
|
|
93
|
+
minY = Math.min(minY, box.y);
|
|
94
|
+
maxX = Math.max(maxX, box.x + box.width);
|
|
95
|
+
maxY = Math.max(maxY, box.y + box.height);
|
|
96
|
+
}
|
|
97
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Résout toute la hiérarchie en coordonnées document, en une passe.
|
|
101
|
+
*
|
|
102
|
+
* La rotation n'entre pas dans le calcul des boîtes : comme pour un rectangle
|
|
103
|
+
* pivoté, la boîte d'un groupe pivoté reste sa boîte non pivotée. C'est ce que
|
|
104
|
+
* le cadre de sélection affiche déjà, et le rendu, lui, applique bien la rotation.
|
|
105
|
+
*/
|
|
106
|
+
export function computeLayout(nodes) {
|
|
107
|
+
const children = childrenByParent(nodes);
|
|
108
|
+
const origin = new Map();
|
|
109
|
+
const bounds = new Map();
|
|
110
|
+
const visit = (node, spaceOrigin) => {
|
|
111
|
+
if (node.type !== "group") {
|
|
112
|
+
const local = normalizedBox(node);
|
|
113
|
+
const box = { x: spaceOrigin.x + local.x, y: spaceOrigin.y + local.y, width: local.width, height: local.height };
|
|
114
|
+
bounds.set(node.id, box);
|
|
115
|
+
return box;
|
|
116
|
+
}
|
|
117
|
+
const groupOrigin = { x: spaceOrigin.x + node.x, y: spaceOrigin.y + node.y };
|
|
118
|
+
origin.set(node.id, groupOrigin);
|
|
119
|
+
const childBoxes = (children.get(node.id) ?? []).map((child) => visit(child, groupOrigin));
|
|
120
|
+
// Un groupe vide n'a pas de contenu à englober : il reste un point, ce qui
|
|
121
|
+
// le laisse sélectionnable dans la liste des calques sans polluer le canvas.
|
|
122
|
+
const box = unionBox(childBoxes) ?? { x: groupOrigin.x, y: groupOrigin.y, width: 0, height: 0 };
|
|
123
|
+
bounds.set(node.id, box);
|
|
124
|
+
return box;
|
|
125
|
+
};
|
|
126
|
+
for (const node of children.get(null) ?? [])
|
|
127
|
+
visit(node, ORIGIN);
|
|
128
|
+
// Les éléments dont le parent a disparu sont rendus à la racine plutôt que
|
|
129
|
+
// d'être silencieusement invisibles.
|
|
130
|
+
for (const node of nodes) {
|
|
131
|
+
if (!bounds.has(node.id))
|
|
132
|
+
visit(node, ORIGIN);
|
|
133
|
+
}
|
|
134
|
+
return { origin, bounds };
|
|
135
|
+
}
|
|
136
|
+
/** Origine de l'espace de coordonnées dans lequel vit un élément. */
|
|
137
|
+
export function spaceOriginOf(layout, parentId) {
|
|
138
|
+
return (parentId === null ? undefined : layout.origin.get(parentId)) ?? ORIGIN;
|
|
139
|
+
}
|
|
140
|
+
export function boundsOf(layout, id) {
|
|
141
|
+
return layout.bounds.get(id) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
142
|
+
}
|
|
143
|
+
/** Boîte englobante commune à plusieurs éléments, en coordonnées document. */
|
|
144
|
+
export function unionBoundsOf(layout, ids) {
|
|
145
|
+
return unionBox(ids.map((id) => layout.bounds.get(id)).filter((box) => box !== undefined));
|
|
146
|
+
}
|
|
147
|
+
// --- Ordre d'empilement -----------------------------------------------------
|
|
148
|
+
export const REORDER_DIRECTIONS = ["front", "back", "forward", "backward"];
|
|
149
|
+
/**
|
|
150
|
+
* Retire d'une sélection les éléments dont un ancêtre est déjà sélectionné.
|
|
151
|
+
*
|
|
152
|
+
* Déplacer un enfant en même temps que son groupe n'a pas de sens : le groupe
|
|
153
|
+
* emporte déjà son contenu, et les deux gestes s'appliqueraient dans des
|
|
154
|
+
* fratries différentes.
|
|
155
|
+
*/
|
|
156
|
+
export function withoutRedundantDescendants(nodes, ids) {
|
|
157
|
+
const selected = new Set(ids);
|
|
158
|
+
return ids.filter((id) => !ancestorsOf(nodes, id).some((ancestor) => selected.has(ancestor.id)));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Change l'ordre d'empilement d'un ou plusieurs éléments.
|
|
162
|
+
*
|
|
163
|
+
* L'empilement est **relatif à la fratrie** : un élément ne peut passer devant
|
|
164
|
+
* que ses frères, jamais devant le contenu d'un autre groupe — c'est le groupe
|
|
165
|
+
* lui-même qu'il faut alors déplacer. Chaque fratrie concernée est réordonnée
|
|
166
|
+
* indépendamment, puis la liste plate est reconstruite en parcours préfixe.
|
|
167
|
+
*
|
|
168
|
+
* `forward`/`backward` décalent d'un cran ; `front`/`back` vont au bout. Un
|
|
169
|
+
* élément déjà au bout de sa fratrie ne bouge pas, et un lot d'éléments garde
|
|
170
|
+
* son ordre relatif d'origine.
|
|
171
|
+
*/
|
|
172
|
+
export function reorderNodes(nodes, ids, direction) {
|
|
173
|
+
const targets = new Set(withoutRedundantDescendants(nodes, ids));
|
|
174
|
+
if (targets.size === 0)
|
|
175
|
+
return nodes;
|
|
176
|
+
const children = childrenByParent(nodes);
|
|
177
|
+
for (const [parentId, siblings] of children) {
|
|
178
|
+
const moving = siblings.filter((node) => targets.has(node.id));
|
|
179
|
+
if (moving.length === 0)
|
|
180
|
+
continue;
|
|
181
|
+
let next = [...siblings];
|
|
182
|
+
if (direction === "front") {
|
|
183
|
+
next = [...next.filter((node) => !targets.has(node.id)), ...moving];
|
|
184
|
+
}
|
|
185
|
+
else if (direction === "back") {
|
|
186
|
+
next = [...moving, ...next.filter((node) => !targets.has(node.id))];
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const step = direction === "forward" ? 1 : -1;
|
|
190
|
+
const indexes = next.map((node, index) => (targets.has(node.id) ? index : -1)).filter((index) => index >= 0);
|
|
191
|
+
// On part du bord vers lequel on pousse, sinon deux éléments adjacents se
|
|
192
|
+
// marchent dessus et le lot se désagrège.
|
|
193
|
+
for (const index of step === 1 ? indexes.reverse() : indexes) {
|
|
194
|
+
const swap = index + step;
|
|
195
|
+
if (swap < 0 || swap >= next.length || targets.has(next[swap].id))
|
|
196
|
+
continue;
|
|
197
|
+
[next[index], next[swap]] = [next[swap], next[index]];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
children.set(parentId, next);
|
|
201
|
+
}
|
|
202
|
+
const emitted = new Set();
|
|
203
|
+
const flat = [];
|
|
204
|
+
const visit = (parentId) => {
|
|
205
|
+
for (const node of children.get(parentId) ?? []) {
|
|
206
|
+
if (emitted.has(node.id))
|
|
207
|
+
continue;
|
|
208
|
+
emitted.add(node.id);
|
|
209
|
+
flat.push(node);
|
|
210
|
+
visit(node.id);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
visit(null);
|
|
214
|
+
// Filet identique à flattenPreOrder : un noeud dont le parent a disparu reste
|
|
215
|
+
// dans la liste plutôt que d'être perdu.
|
|
216
|
+
for (const node of nodes) {
|
|
217
|
+
if (!emitted.has(node.id)) {
|
|
218
|
+
emitted.add(node.id);
|
|
219
|
+
flat.push(node);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return flat;
|
|
223
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
const SERVER_NAME = "mcp-design";
|
|
4
|
+
const PACKAGE_NAME = "@imenam/mcp-design";
|
|
5
|
+
/** Slashes POSIX : évite les backslashes échappés dans le JSON, et Node les accepte sous Windows. */
|
|
6
|
+
function toPosixPath(dir) {
|
|
7
|
+
return dir.replace(/\\/g, "/");
|
|
8
|
+
}
|
|
9
|
+
/** "mcp-design" -> "Mcp-design" */
|
|
10
|
+
function capitalize(value) {
|
|
11
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
12
|
+
}
|
|
13
|
+
/** "mon_projet-web" -> "Mon Projet Web" */
|
|
14
|
+
function toDisplayName(folder) {
|
|
15
|
+
return folder.split(/[-_]+/).filter(Boolean).map(capitalize).join(" ");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Valeurs volontairement vides : chaque variable est inactive tant qu'elle n'est
|
|
19
|
+
* pas remplie (PROXY_URL vide = GUI désactivée), donc le fichier généré est
|
|
20
|
+
* utilisable tel quel, jamais à moitié cassé.
|
|
21
|
+
*/
|
|
22
|
+
function buildServerEntry(codeRoot) {
|
|
23
|
+
const folder = path.basename(codeRoot) || "design";
|
|
24
|
+
return {
|
|
25
|
+
command: "npx",
|
|
26
|
+
args: ["-y", PACKAGE_NAME],
|
|
27
|
+
env: {
|
|
28
|
+
PROXY_URL: "http://localhost:",
|
|
29
|
+
APP_GROUP: capitalize(folder),
|
|
30
|
+
APP_NAME: `Design ${toDisplayName(folder)}`,
|
|
31
|
+
APP_PATH: `/design-${folder}`,
|
|
32
|
+
MCP_DESIGN_DATA_DIR: toPosixPath(path.join(codeRoot, ".design-data")),
|
|
33
|
+
MCP_LOG_DIR: "",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const GENERATED_VARS = [
|
|
38
|
+
["PROXY_URL", "URL du proxy pour exposer la GUI (compléter le port)"],
|
|
39
|
+
["APP_GROUP", "groupe d'affichage dans le dashboard du proxy"],
|
|
40
|
+
["APP_NAME", "nom affiché de la GUI dans le dashboard du proxy"],
|
|
41
|
+
["APP_PATH", "chemin de montage de la GUI derrière le proxy"],
|
|
42
|
+
["MCP_DESIGN_DATA_DIR", "répertoire de stockage des designs (pré-rempli)"],
|
|
43
|
+
["MCP_LOG_DIR", "répertoire des logs (défaut : <data>/logs)"],
|
|
44
|
+
];
|
|
45
|
+
export function runSetupMcp({ cwd, force }) {
|
|
46
|
+
const target = path.join(cwd, ".mcp.json");
|
|
47
|
+
let config = {};
|
|
48
|
+
const exists = fs.existsSync(target);
|
|
49
|
+
if (exists) {
|
|
50
|
+
const raw = fs.readFileSync(target, "utf-8").trim();
|
|
51
|
+
if (raw !== "") {
|
|
52
|
+
try {
|
|
53
|
+
config = JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error(`Erreur : ${target} contient du JSON invalide (${err.message}).`);
|
|
57
|
+
console.error("Corrigez le fichier ou supprimez-le, puis relancez la commande.");
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
|
62
|
+
console.error(`Erreur : ${target} ne contient pas un objet JSON.`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof config.mcpServers !== "object" || config.mcpServers === null || Array.isArray(config.mcpServers)) {
|
|
67
|
+
config.mcpServers = {};
|
|
68
|
+
}
|
|
69
|
+
const alreadyPresent = Object.prototype.hasOwnProperty.call(config.mcpServers, SERVER_NAME);
|
|
70
|
+
if (alreadyPresent && !force) {
|
|
71
|
+
console.error(`Le serveur "${SERVER_NAME}" est déjà configuré dans ${target}.`);
|
|
72
|
+
console.error("Relancez avec --force pour écraser cette entrée (les autres serveurs sont préservés).");
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
config.mcpServers[SERVER_NAME] = buildServerEntry(cwd);
|
|
76
|
+
fs.writeFileSync(target, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
77
|
+
const action = alreadyPresent ? "remplacée" : exists ? "ajoutée" : "créée";
|
|
78
|
+
console.log(`Configuration ${SERVER_NAME} ${action} dans ${target}`);
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log('À compléter dans le champ "env" :');
|
|
81
|
+
for (const [name, description] of GENERATED_VARS) {
|
|
82
|
+
console.log(` ${name.padEnd(24)} ${description}`);
|
|
83
|
+
}
|
|
84
|
+
console.log("");
|
|
85
|
+
console.log("Une variable laissée vide est simplement inactive.");
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type Design, type DesignSummary } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
export interface CreateDesignInput {
|
|
3
|
+
name: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
background?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface MutateOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Révision sur laquelle l'édition est basée. Quand elle est fournie et que le
|
|
11
|
+
* fichier a avancé entre-temps, l'écriture est refusée : sans cette garde, un
|
|
12
|
+
* éditeur parti d'un état périmé réécrit le document entier et efface le
|
|
13
|
+
* travail de l'autre.
|
|
14
|
+
*/
|
|
15
|
+
baseRevision?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
|
|
19
|
+
* Pas de base de données — un design tient dans quelques dizaines de Ko et le
|
|
20
|
+
* format doit rester lisible/diffable à la main.
|
|
21
|
+
*/
|
|
22
|
+
export declare class DesignStore {
|
|
23
|
+
private readonly dataDir;
|
|
24
|
+
private readonly designsDir;
|
|
25
|
+
/**
|
|
26
|
+
* Cache mémoire : la GUI sauvegarde à chaque geste, relire le disque à chaque
|
|
27
|
+
* fois serait inutile. Chaque entrée retient la signature du fichier au moment
|
|
28
|
+
* de la lecture — plusieurs processus (une seconde GUI, un second agent MCP)
|
|
29
|
+
* partagent le même dataDir, le cache ne peut donc jamais faire autorité seul.
|
|
30
|
+
*/
|
|
31
|
+
private cache;
|
|
32
|
+
constructor(rootDir: string);
|
|
33
|
+
getDataDir(): string;
|
|
34
|
+
private filePath;
|
|
35
|
+
/** Signature du fichier, ou null s'il n'existe pas encore. */
|
|
36
|
+
private stampOf;
|
|
37
|
+
private remember;
|
|
38
|
+
private write;
|
|
39
|
+
/**
|
|
40
|
+
* Sérialise le cycle lire-modifier-écrire entre processus. `wx` échoue si le
|
|
41
|
+
* fichier existe déjà : c'est l'opération atomique qui départage deux
|
|
42
|
+
* écrivains concurrents. Non réentrant — ne jamais appeler depuis `fn`.
|
|
43
|
+
*/
|
|
44
|
+
private withLock;
|
|
45
|
+
/** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
|
|
46
|
+
private allocateId;
|
|
47
|
+
list(): DesignSummary[];
|
|
48
|
+
has(id: string): boolean;
|
|
49
|
+
get(id: string): Design;
|
|
50
|
+
create(input: CreateDesignInput): Design;
|
|
51
|
+
/**
|
|
52
|
+
* Applique une mutation et incrémente la révision. `editor` distingue une
|
|
53
|
+
* modification de l'agent d'une modification faite à la main dans la GUI —
|
|
54
|
+
* c'est ce qui permet à get_changes de rester lisible.
|
|
55
|
+
*/
|
|
56
|
+
mutate(id: string, editor: "agent" | "user", fn: (design: Design) => void, options?: MutateOptions): Design;
|
|
57
|
+
private mutateLocked;
|
|
58
|
+
/** Fige l'état courant comme nouvelle référence de comparaison. */
|
|
59
|
+
markSynced(id: string): Design;
|
|
60
|
+
delete(id: string): void;
|
|
61
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { DesignSchema, } from "../../shared/schemas/design.schema.js";
|
|
4
|
+
import { DesignError } from "./node-factory.js";
|
|
5
|
+
import { normalizeHierarchy } from "./hierarchy.js";
|
|
6
|
+
import { migrateDesign } from "./migrate.js";
|
|
7
|
+
import { shortId, slugify } from "./ids.js";
|
|
8
|
+
import { countChanges } from "./diff.js";
|
|
9
|
+
/** Au-delà de ce délai, un verrou est réputé abandonné (processus tué en cours d'écriture). */
|
|
10
|
+
const LOCK_STALE_MS = 10_000;
|
|
11
|
+
const LOCK_RETRY_MS = 25;
|
|
12
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
13
|
+
/**
|
|
14
|
+
* Pause bloquante. Le store est entièrement synchrone — le rendre asynchrone
|
|
15
|
+
* pour l'attente d'un verrou contaminerait tous les appelants.
|
|
16
|
+
*/
|
|
17
|
+
function sleepSync(ms) {
|
|
18
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
|
|
22
|
+
* Pas de base de données — un design tient dans quelques dizaines de Ko et le
|
|
23
|
+
* format doit rester lisible/diffable à la main.
|
|
24
|
+
*/
|
|
25
|
+
export class DesignStore {
|
|
26
|
+
dataDir;
|
|
27
|
+
designsDir;
|
|
28
|
+
/**
|
|
29
|
+
* Cache mémoire : la GUI sauvegarde à chaque geste, relire le disque à chaque
|
|
30
|
+
* fois serait inutile. Chaque entrée retient la signature du fichier au moment
|
|
31
|
+
* de la lecture — plusieurs processus (une seconde GUI, un second agent MCP)
|
|
32
|
+
* partagent le même dataDir, le cache ne peut donc jamais faire autorité seul.
|
|
33
|
+
*/
|
|
34
|
+
cache = new Map();
|
|
35
|
+
constructor(rootDir) {
|
|
36
|
+
// MCP_DESIGN_DATA_DIR est prioritaire ; MCP_DATA_DIR reste accepté pour rester
|
|
37
|
+
// aligné sur la convention commune aux autres MCPs de l'écosystème.
|
|
38
|
+
const configured = process.env.MCP_DESIGN_DATA_DIR?.trim() || process.env.MCP_DATA_DIR?.trim();
|
|
39
|
+
this.dataDir = configured ? path.resolve(configured) : path.join(rootDir, ".design-data");
|
|
40
|
+
this.designsDir = path.join(this.dataDir, "designs");
|
|
41
|
+
fs.mkdirSync(this.designsDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
getDataDir() {
|
|
44
|
+
return this.dataDir;
|
|
45
|
+
}
|
|
46
|
+
filePath(id) {
|
|
47
|
+
return path.join(this.designsDir, `${id}.json`);
|
|
48
|
+
}
|
|
49
|
+
/** Signature du fichier, ou null s'il n'existe pas encore. */
|
|
50
|
+
stampOf(id) {
|
|
51
|
+
try {
|
|
52
|
+
const stat = fs.statSync(this.filePath(id));
|
|
53
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
remember(design) {
|
|
60
|
+
const stamp = this.stampOf(design.id);
|
|
61
|
+
// Sans signature lisible, on préfère ne rien cacher : la prochaine lecture
|
|
62
|
+
// repartira du disque plutôt que de servir un état peut-être dépassé.
|
|
63
|
+
if (stamp)
|
|
64
|
+
this.cache.set(design.id, { design, stamp });
|
|
65
|
+
else
|
|
66
|
+
this.cache.delete(design.id);
|
|
67
|
+
}
|
|
68
|
+
write(design) {
|
|
69
|
+
// Écriture atomique : un crash pendant l'écriture ne laisse pas un JSON tronqué.
|
|
70
|
+
const target = this.filePath(design.id);
|
|
71
|
+
const tmp = `${target}.tmp`;
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(design, null, 2), "utf-8");
|
|
73
|
+
fs.renameSync(tmp, target);
|
|
74
|
+
// La signature est relue après le rename : sans ça, la lecture suivante
|
|
75
|
+
// prendrait notre propre écriture pour une modification externe.
|
|
76
|
+
this.remember(design);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Sérialise le cycle lire-modifier-écrire entre processus. `wx` échoue si le
|
|
80
|
+
* fichier existe déjà : c'est l'opération atomique qui départage deux
|
|
81
|
+
* écrivains concurrents. Non réentrant — ne jamais appeler depuis `fn`.
|
|
82
|
+
*/
|
|
83
|
+
withLock(id, fn) {
|
|
84
|
+
const lockPath = `${this.filePath(id)}.lock`;
|
|
85
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
86
|
+
let fd;
|
|
87
|
+
for (;;) {
|
|
88
|
+
try {
|
|
89
|
+
fd = fs.openSync(lockPath, "wx");
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (err.code !== "EEXIST")
|
|
94
|
+
throw err;
|
|
95
|
+
try {
|
|
96
|
+
// Un processus tué laisse son verrou derrière lui : passé le délai
|
|
97
|
+
// d'obsolescence on le casse, sinon le design serait bloqué à vie.
|
|
98
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
99
|
+
fs.unlinkSync(lockPath);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue; // Verrou disparu entre le open et le stat : on retente aussitôt.
|
|
105
|
+
}
|
|
106
|
+
if (Date.now() > deadline) {
|
|
107
|
+
throw new DesignError(`Design "${id}" is being written by another editor and did not free up in ${LOCK_TIMEOUT_MS} ms. Try again in a moment.`);
|
|
108
|
+
}
|
|
109
|
+
sleepSync(LOCK_RETRY_MS);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return fn();
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
try {
|
|
118
|
+
fs.unlinkSync(lockPath);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Verrou déjà cassé pour obsolescence par un autre processus.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
|
|
126
|
+
allocateId(name) {
|
|
127
|
+
const base = slugify(name);
|
|
128
|
+
if (!fs.existsSync(this.filePath(base)))
|
|
129
|
+
return base;
|
|
130
|
+
for (let i = 2; i < 100; i++) {
|
|
131
|
+
const candidate = `${base}-${i}`;
|
|
132
|
+
if (!fs.existsSync(this.filePath(candidate)))
|
|
133
|
+
return candidate;
|
|
134
|
+
}
|
|
135
|
+
return `${base}-${shortId("x", 4)}`;
|
|
136
|
+
}
|
|
137
|
+
list() {
|
|
138
|
+
const files = fs.existsSync(this.designsDir)
|
|
139
|
+
? fs.readdirSync(this.designsDir).filter((f) => f.endsWith(".json"))
|
|
140
|
+
: [];
|
|
141
|
+
const summaries = [];
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
const id = file.slice(0, -".json".length);
|
|
144
|
+
let design;
|
|
145
|
+
try {
|
|
146
|
+
design = this.get(id);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
continue; // Fichier corrompu ou hors schéma : il ne doit pas casser la liste.
|
|
150
|
+
}
|
|
151
|
+
summaries.push({
|
|
152
|
+
id: design.id,
|
|
153
|
+
name: design.name,
|
|
154
|
+
width: design.width,
|
|
155
|
+
height: design.height,
|
|
156
|
+
nodeCount: design.nodes.length,
|
|
157
|
+
updatedAt: design.updatedAt,
|
|
158
|
+
revision: design.revision,
|
|
159
|
+
lastEditor: design.lastEditor,
|
|
160
|
+
pendingChanges: countChanges(design.baseline.nodes, design.nodes),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
164
|
+
}
|
|
165
|
+
has(id) {
|
|
166
|
+
return fs.existsSync(this.filePath(id));
|
|
167
|
+
}
|
|
168
|
+
get(id) {
|
|
169
|
+
const stamp = this.stampOf(id);
|
|
170
|
+
const cached = this.cache.get(id);
|
|
171
|
+
// Le cache ne vaut que tant que le fichier n'a pas bougé sous nos pieds.
|
|
172
|
+
if (cached && stamp && cached.stamp.mtimeMs === stamp.mtimeMs && cached.stamp.size === stamp.size) {
|
|
173
|
+
return cached.design;
|
|
174
|
+
}
|
|
175
|
+
this.cache.delete(id);
|
|
176
|
+
const file = this.filePath(id);
|
|
177
|
+
if (!fs.existsSync(file)) {
|
|
178
|
+
throw new DesignError(`Design "${id}" not found. Use list_designs to see the available designs.`);
|
|
179
|
+
}
|
|
180
|
+
let raw;
|
|
181
|
+
try {
|
|
182
|
+
raw = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
throw new DesignError(`Design "${id}" is not readable: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
// Mise à niveau du format avant validation : un fichier écrit par une
|
|
188
|
+
// version antérieure reste lisible, et n'est réécrit qu'en cas de changement.
|
|
189
|
+
const migrated = migrateDesign(raw);
|
|
190
|
+
const parsed = DesignSchema.safeParse(migrated ?? raw);
|
|
191
|
+
if (!parsed.success) {
|
|
192
|
+
throw new DesignError(`Design "${id}" does not match the expected schema: ${parsed.error.issues
|
|
193
|
+
.map((i) => `${i.path.join(".")} ${i.message}`)
|
|
194
|
+
.join("; ")}`);
|
|
195
|
+
}
|
|
196
|
+
if (migrated) {
|
|
197
|
+
// Best effort : un répertoire de données en lecture seule ne doit pas
|
|
198
|
+
// empêcher de lire le design, la migration reste appliquée en mémoire.
|
|
199
|
+
// Écriture volontairement hors verrou : `get` est appelé depuis l'intérieur
|
|
200
|
+
// de `withLock`, et le verrou n'est pas réentrant. Le contenu écrit est
|
|
201
|
+
// celui qu'on vient de lire, la mise à niveau ne perd donc rien.
|
|
202
|
+
try {
|
|
203
|
+
this.write(parsed.data);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
this.cache.delete(id);
|
|
207
|
+
}
|
|
208
|
+
return parsed.data;
|
|
209
|
+
}
|
|
210
|
+
if (stamp)
|
|
211
|
+
this.cache.set(id, { design: parsed.data, stamp });
|
|
212
|
+
return parsed.data;
|
|
213
|
+
}
|
|
214
|
+
create(input) {
|
|
215
|
+
const name = input.name?.trim();
|
|
216
|
+
if (!name)
|
|
217
|
+
throw new DesignError(`"name" is required to create a design.`);
|
|
218
|
+
const width = input.width ?? 1440;
|
|
219
|
+
const height = input.height ?? 900;
|
|
220
|
+
if (!Number.isFinite(width) || width <= 0)
|
|
221
|
+
throw new DesignError(`"width" must be a positive number.`);
|
|
222
|
+
if (!Number.isFinite(height) || height <= 0)
|
|
223
|
+
throw new DesignError(`"height" must be a positive number.`);
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
const design = {
|
|
226
|
+
id: this.allocateId(name),
|
|
227
|
+
name,
|
|
228
|
+
width,
|
|
229
|
+
height,
|
|
230
|
+
background: input.background ?? "#ffffff",
|
|
231
|
+
nodes: [],
|
|
232
|
+
createdAt: now,
|
|
233
|
+
updatedAt: now,
|
|
234
|
+
revision: 0,
|
|
235
|
+
lastEditor: "agent",
|
|
236
|
+
baseline: { revision: 0, at: now, nodes: [] },
|
|
237
|
+
};
|
|
238
|
+
this.write(design);
|
|
239
|
+
return design;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Applique une mutation et incrémente la révision. `editor` distingue une
|
|
243
|
+
* modification de l'agent d'une modification faite à la main dans la GUI —
|
|
244
|
+
* c'est ce qui permet à get_changes de rester lisible.
|
|
245
|
+
*/
|
|
246
|
+
mutate(id, editor, fn, options = {}) {
|
|
247
|
+
// Tout le cycle lire-modifier-écrire tient dans le verrou : hors de là, deux
|
|
248
|
+
// processus peuvent lire le même état puis s'écraser l'un l'autre.
|
|
249
|
+
return this.withLock(id, () => this.mutateLocked(id, editor, fn, options));
|
|
250
|
+
}
|
|
251
|
+
mutateLocked(id, editor, fn, options) {
|
|
252
|
+
const current = this.get(id);
|
|
253
|
+
if (options.baseRevision !== undefined && options.baseRevision !== current.revision) {
|
|
254
|
+
throw new DesignError(`Design "${id}" has changed since it was loaded: your edit is based on revision ${options.baseRevision}, ` +
|
|
255
|
+
`the stored design is at revision ${current.revision}. Reload it with get_design and reapply your change — ` +
|
|
256
|
+
`writing now would discard the other editor's work.`);
|
|
257
|
+
}
|
|
258
|
+
const draft = structuredClone(current);
|
|
259
|
+
fn(draft);
|
|
260
|
+
// Les invariants de hiérarchie sont vérifiés ici, une seule fois : aucun
|
|
261
|
+
// appelant — outil MCP ou sauvegarde depuis la GUI — ne peut les contourner.
|
|
262
|
+
draft.nodes = normalizeHierarchy(draft.nodes);
|
|
263
|
+
draft.revision = current.revision + 1;
|
|
264
|
+
draft.updatedAt = new Date().toISOString();
|
|
265
|
+
draft.lastEditor = editor;
|
|
266
|
+
const parsed = DesignSchema.safeParse(draft);
|
|
267
|
+
if (!parsed.success) {
|
|
268
|
+
throw new DesignError(`Resulting design is invalid: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
|
|
269
|
+
}
|
|
270
|
+
this.write(parsed.data);
|
|
271
|
+
return parsed.data;
|
|
272
|
+
}
|
|
273
|
+
/** Fige l'état courant comme nouvelle référence de comparaison. */
|
|
274
|
+
markSynced(id) {
|
|
275
|
+
return this.withLock(id, () => {
|
|
276
|
+
const current = this.get(id);
|
|
277
|
+
const now = new Date().toISOString();
|
|
278
|
+
const next = {
|
|
279
|
+
...structuredClone(current),
|
|
280
|
+
baseline: { revision: current.revision, at: now, nodes: structuredClone(current.nodes) },
|
|
281
|
+
};
|
|
282
|
+
this.write(next);
|
|
283
|
+
return next;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
delete(id) {
|
|
287
|
+
const file = this.filePath(id);
|
|
288
|
+
if (!fs.existsSync(file)) {
|
|
289
|
+
throw new DesignError(`Design "${id}" not found.`);
|
|
290
|
+
}
|
|
291
|
+
fs.unlinkSync(file);
|
|
292
|
+
this.cache.delete(id);
|
|
293
|
+
}
|
|
294
|
+
}
|