@imenam/mcp-design 1.0.6 → 1.0.8
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 +44 -6
- package/dist/shared/node-defaults.d.ts +1 -1
- package/dist/shared/node-defaults.js +9 -3
- package/dist/shared/node-fields.js +6 -2
- package/dist/shared/render.d.ts +18 -0
- package/dist/shared/render.js +22 -0
- package/dist/src/cli/setup-mcp.d.ts +20 -1
- package/dist/src/cli/setup-mcp.js +64 -6
- package/dist/src/core/node-factory.js +15 -0
- package/dist/src/gui-worker.js +71 -23
- package/dist/src/index.js +45 -21
- package/dist/src/tools/export-design.d.ts +2 -2
- package/dist/src/tools/export-design.js +28 -9
- package/dist/src/tools/relay.d.ts +27 -0
- package/dist/src/tools/relay.js +67 -0
- package/dist/src/tools/screenshot-design.js +14 -8
- package/dist/src/tools/types.d.ts +20 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/index-DBZNXgF_.js +10 -0
- package/gui/dist/assets/{search-C-KcHHKM.js → search-CSwiX0lv.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +60 -60
- package/scripts/generate-icons.js +85 -85
- package/gui/dist/assets/index-B-q92Pfu.js +0 -10
package/README.md
CHANGED
|
@@ -28,6 +28,18 @@ npx -y @imenam/mcp-design --claude-setup-mcp
|
|
|
28
28
|
|
|
29
29
|
La commande crée (ou complète) le `.mcp.json` du répertoire courant avec une entrée `mcp-design` dont les variables sont pré-remplies. Ajoutez `--force` pour remplacer une entrée existante ; les autres serveurs sont préservés.
|
|
30
30
|
|
|
31
|
+
Deux flags permettent de choisir directement comment la GUI sera exposée, sans éditer le JSON ensuite :
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# GUI derrière le proxy central
|
|
35
|
+
npx -y @imenam/mcp-design --claude-setup-mcp --proxy_url=http://localhost:3000
|
|
36
|
+
|
|
37
|
+
# GUI en accès direct, sans proxy : http://localhost:4500
|
|
38
|
+
npx -y @imenam/mcp-design --claude-setup-mcp --app_port=4500
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Les deux flags sont exclusifs — la commande échoue si les deux sont passés.
|
|
42
|
+
|
|
31
43
|
Exemple d'entrée générée :
|
|
32
44
|
|
|
33
45
|
```json
|
|
@@ -38,6 +50,7 @@ Exemple d'entrée générée :
|
|
|
38
50
|
"args": ["-y", "@imenam/mcp-design"],
|
|
39
51
|
"env": {
|
|
40
52
|
"PROXY_URL": "http://localhost:3000",
|
|
53
|
+
"APP_PORT": "",
|
|
41
54
|
"APP_GROUP": "Mon Projet",
|
|
42
55
|
"APP_NAME": "Design Mon Projet",
|
|
43
56
|
"APP_PATH": "/design-mon-projet",
|
|
@@ -53,13 +66,21 @@ Exemple d'entrée générée :
|
|
|
53
66
|
|
|
54
67
|
| Variable | Rôle |
|
|
55
68
|
|---|---|
|
|
56
|
-
| `PROXY_URL` | URL du [proxy central](https://github.com/imenam/proxy-setup)
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
69
|
+
| `PROXY_URL` | URL du [proxy central](https://github.com/imenam/proxy-setup) : le proxy attribue le port et monte la GUI sous `APP_PATH`. |
|
|
70
|
+
| `APP_PORT` | Port local utilisé **quand `PROXY_URL` est absent** : la GUI est servie directement sur `http://localhost:<port>`, à la racine. Permet d'utiliser l'interface sans le hub de centralisation. |
|
|
71
|
+
| `APP_PATH` | Chemin de montage de la GUI derrière le proxy (défaut `/design-gui`). Ignoré en mode `APP_PORT`. `PROXY_APP_PATH` puis `GUI_PATH` sont acceptés en repli. |
|
|
72
|
+
| `APP_NAME` | Nom affiché dans le dashboard du proxy. `PROXY_APP_NAME` puis `GUI_NAME` sont acceptés en repli. |
|
|
73
|
+
| `APP_PATH_PREFIX` | Préfixe de namespace ajouté au chemin **par défaut** (ex. `/abc123` → `/abc123/design-gui`). Sert à isoler plusieurs instances sous un même proxy. |
|
|
59
74
|
| `APP_GROUP` | Section repliable du dashboard du proxy (facultatif). |
|
|
60
75
|
| `MCP_DESIGN_DATA_DIR` | Répertoire de stockage des designs. `MCP_DATA_DIR` est accepté en repli. Défaut : `<package>/.design-data`. |
|
|
61
76
|
| `MCP_LOG_DIR` | Répertoire des logs. Défaut : `<data>/logs`. |
|
|
62
77
|
|
|
78
|
+
`PROXY_URL` et `APP_PORT` s'excluent : `PROXY_URL` prime si les deux sont renseignés. **Si aucune des deux n'est définie, la GUI est simplement désactivée** — les outils MCP continuent de fonctionner.
|
|
79
|
+
|
|
80
|
+
`APP_PATH` / `APP_NAME` sont les noms canoniques ; `PROXY_APP_*` puis `GUI_*` restent acceptés, dans cet ordre de priorité décroissante. La règle de `APP_PATH_PREFIX` est simple : **il ne préfixe que le chemin par défaut**. Dès que `APP_PATH` (ou l'un de ses replis) est renseigné, cette valeur est prise telle quelle, sans préfixe — la saisie manuelle l'emporte toujours. Les chemins sont normalisés (un seul `/` de séparation, pas de `/` final) et le préfixage est idempotent. Le `<base href>` de l'interface suit le chemin réellement enregistré, assets compris.
|
|
81
|
+
|
|
82
|
+
La résolution est assurée par le paquet partagé [`@imenam/mcp-gui-interface`](https://www.npmjs.com/package/@imenam/mcp-gui-interface), qui documente la règle en détail.
|
|
83
|
+
|
|
63
84
|
Un design est un simple fichier JSON dans `<data>/designs/<id>.json` — lisible, diffable, versionnable.
|
|
64
85
|
|
|
65
86
|
---
|
|
@@ -146,6 +167,19 @@ Le texte n'est **jamais re-wrappé automatiquement** : seuls les `\n` explicites
|
|
|
146
167
|
|
|
147
168
|
`lineHeight` est une **distance en pixels entre deux lignes de base**, pas un multiplicateur de `fontSize` : avec `fontSize: 64`, on écrit `76`, pas `1.2`. C'est l'unité que les agents emploient spontanément, et la seule qui se lise dans la même échelle que le reste. Une valeur sous 4 est donc refusée à l'écriture avec un message explicite, et les fichiers écrits avant ce changement sont convertis à la lecture (`ratio x fontSize`) par [`src/core/migrate.ts`](src/core/migrate.ts). Champ omis = `fontSize x 1.35`.
|
|
148
169
|
|
|
170
|
+
#### La boîte d'un texte épouse son texte
|
|
171
|
+
|
|
172
|
+
Le bloc de lignes est **centré verticalement dans la boîte** du nœud : c'est ce qui permet de centrer un libellé dans un bouton sans calculer de décalage. Encore faut-il que la boîte soit celle qu'on croit — une hauteur par défaut plus grande que le texte le fait descendre sous le point posé.
|
|
173
|
+
|
|
174
|
+
Donc : **un nœud `text` créé sans `height` prend la hauteur exacte de son texte** (`lignes x lineHeight`). Le centrage n'a alors plus rien à décaler, et la première ligne commence bien à `y`, à gauche de `x`. Ajouter une ligne agrandit la boîte au lieu de la faire déborder : une boîte qui épousait son texte continue de l'épouser, à chaque changement de `text`, `fontSize` ou `lineHeight`, à l'écriture par l'agent comme à l'édition dans la GUI.
|
|
175
|
+
|
|
176
|
+
Une hauteur **explicite**, elle, n'est jamais retouchée — c'est le geste qui dit « centre ce libellé » :
|
|
177
|
+
|
|
178
|
+
```jsonc
|
|
179
|
+
{ "type": "text", "x": 40, "y": 120, "text": "Titre", "fontSize": 32 } // boîte = 43.2px, texte posé à y
|
|
180
|
+
{ "type": "text", "x": 40, "y": 100, "height": 26, "text": "OK", "fontSize": 12 } // libellé centré dans son bouton
|
|
181
|
+
```
|
|
182
|
+
|
|
149
183
|
### Groupes réutilisables
|
|
150
184
|
|
|
151
185
|
Quand un même bloc revient plusieurs fois — une carte, une ligne de liste, un bouton —, il ne se recopie pas : il se **construit une fois** en groupe, puis s'**instancie**. Une `instance` ne stocke aucun contenu, seulement l'identifiant du groupe qu'elle reproduit ; le sous-arbre est reconstitué au rendu. Éditer le groupe met donc à jour **toutes** ses instances d'un coup, et un bloc répété reste une seule chose à lire et à modifier. C'est le fonctionnement d'un composant principal dans Figma.
|
|
@@ -187,8 +221,12 @@ Un lot est intégralement validé avant la moindre écriture : une entrée inval
|
|
|
187
221
|
|---|---|
|
|
188
222
|
| `get_changes` | Liste tout ce qui a été ajouté, modifié, supprimé ou réordonné depuis la dernière baseline, champ par champ (`x: 40 -> 240`). |
|
|
189
223
|
| `mark_synced` | Fige l'état courant comme nouvelle baseline. |
|
|
190
|
-
| `export_design` | Rend la planche en `html`, `jsx`, `svg`, `json` ou `summary`. `
|
|
191
|
-
| `screenshot_design` | Rasterise la planche en PNG et le renvoie **en image** à l'agent. `node_id` cadre la capture sur un groupe (ou n'importe quel élément), `scale` zoome, `padding` ajoute de la marge, `
|
|
224
|
+
| `export_design` | Rend la planche en `html`, `jsx`, `svg`, `json` ou `summary`. `destination_path` + `filename` écrivent un fichier **sur la machine de l'agent** au lieu de renvoyer le contenu. |
|
|
225
|
+
| `screenshot_design` | Rasterise la planche en PNG et le renvoie **en image** à l'agent. `node_id` cadre la capture sur un groupe (ou n'importe quel élément), `scale` zoome, `padding` ajoute de la marge, `destination_path` + `filename` écrivent un `.png` **sur la machine de l'agent** au lieu de renvoyer l'image. |
|
|
226
|
+
|
|
227
|
+
> **Où le fichier est écrit.** `destination_path` est un **dossier absolu** sur la machine de l'agent, `filename` le nom que le fichier y prend. Omis, les deux outils se comportent comme avant : le contenu (ou l'image) revient dans la réponse, sans fichier.
|
|
228
|
+
>
|
|
229
|
+
> En stdio, ce MCP tourne à côté de l'agent et écrit lui-même. Derrière [`@imenam/mcp-http-gateway`](https://www.npmjs.com/package/@imenam/mcp-http-gateway), il tourne sur la machine du gateway et ne peut pas atteindre le disque de l'agent : il déclare alors la paire `destination_path` / `destination_relay`, renvoie les octets, et le relais — qui tourne, lui, sur la machine de l'agent — les y écrit. `destination_relay` est rempli par le relais, jamais par l'agent, et cette voie exige `GATEWAY_INLINE_WRITE_ROOTS` dans le bloc `env` du relais.
|
|
192
230
|
|
|
193
231
|
**`get_changes` n'acquitte jamais ce qu'il montre.** Seul `mark_synced` déplace la baseline, et il ne doit être appelé qu'une fois les modifications réellement reportées dans le code. Un simple appel de lecture ne peut donc pas faire disparaître un diff que l'agent n'a pas encore traité.
|
|
194
232
|
|
|
@@ -225,7 +263,7 @@ Trois colonnes : la liste des planches, le canvas, l'inspecteur et les calques.
|
|
|
225
263
|
- **Outils** — sélection, rectangle, ellipse, ligne, texte, icône. Glisser pour dimensionner, cliquer pour poser à la taille par défaut. L'outil icône ouvre d'abord son sélecteur : poser une icône suppose d'avoir choisi laquelle.
|
|
226
264
|
- **Édition** — déplacement au glisser, 8 poignées de redimensionnement (2 extrémités pour une ligne), sélection multiple au rectangle d'encadrement ou au Maj+clic.
|
|
227
265
|
- **Groupes** — `Ctrl+G` groupe la sélection, `Ctrl+Maj+G` la dissout. Un clic dans le canvas sélectionne le **groupe le plus haut** ; `Alt+clic` vise l'élément exact à l'intérieur. Déplacer un groupe déplace tout son contenu.
|
|
228
|
-
- **Texte** — double-clic pour éditer en place, avec la même métrique qu'au rendu final.
|
|
266
|
+
- **Texte** — double-clic pour éditer en place, avec la même métrique qu'au rendu final. Une boîte à la taille de son texte le reste : ajouter une ligne l'agrandit d'autant.
|
|
229
267
|
- **Inspecteur** — position, taille, rotation, opacité, couleurs, épaisseur, arrondi, contenu, taille et graisse de police, interligne (en px), alignement, choix de l'icône. Pour un groupe, la taille est affichée en lecture seule puisqu'elle suit son contenu ; pour une icône, un seul champ de taille, puisqu'elle est carrée.
|
|
230
268
|
- **Calques** — arbre repliable, ordre d'empilement, sélection, montée/descente d'un cran.
|
|
231
269
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultLineHeight } from "./render.js";
|
|
1
2
|
import { DEFAULT_ICON_STROKE_WIDTH, ICON_GRID } from "./icons/index.js";
|
|
2
3
|
export const NODE_KINDS = ["rect", "ellipse", "line", "text", "icon", "group", "instance"];
|
|
3
4
|
/** Types que l'utilisateur peut dessiner directement ; un groupe se cree a partir d'une selection. */
|
|
@@ -8,7 +9,11 @@ export const NODE_DEFAULTS = {
|
|
|
8
9
|
line: { width: 200, height: 0, stroke: "#1a1a18", strokeWidth: 2 },
|
|
9
10
|
text: {
|
|
10
11
|
width: 260,
|
|
11
|
-
|
|
12
|
+
// Une ligne : la boîte par défaut épouse le texte par défaut. Toute autre
|
|
13
|
+
// hauteur ferait descendre la première ligne sous le point posé, puisque le
|
|
14
|
+
// bloc de lignes est centré dans sa boîte. Un texte créé sans hauteur la
|
|
15
|
+
// recalcule de toute façon sur son propre contenu.
|
|
16
|
+
height: defaultLineHeight(24),
|
|
12
17
|
text: "Texte",
|
|
13
18
|
color: "#1a1a18",
|
|
14
19
|
fontSize: 24,
|
|
@@ -16,8 +21,9 @@ export const NODE_DEFAULTS = {
|
|
|
16
21
|
fontWeight: 400,
|
|
17
22
|
fontStyle: "normal",
|
|
18
23
|
textAlign: "left",
|
|
19
|
-
//
|
|
20
|
-
|
|
24
|
+
// `lineHeight` n'a volontairement pas de valeur par défaut : figée, elle
|
|
25
|
+
// rendrait un texte de 64px sur un interligne de 32. Absente, elle suit la
|
|
26
|
+
// taille de police (fontSize x 1.35), comme l'annonce sa documentation.
|
|
21
27
|
},
|
|
22
28
|
// Taille de la grille de Lucide : une icone posee sans consigne rend au
|
|
23
29
|
// format pour lequel son trace a ete dessine.
|
|
@@ -22,7 +22,10 @@ export const NODE_FIELD_DOCS = {
|
|
|
22
22
|
width: "Width of the bounding box, in pixels. On a line, the horizontal delta from start to end — it may be negative. " +
|
|
23
23
|
"On a group, derived from the content and read-only.",
|
|
24
24
|
height: "Height of the bounding box, in pixels. On a line, the vertical delta from start to end — it may be negative. " +
|
|
25
|
-
"On a group, derived from the content and read-only."
|
|
25
|
+
"On a group, derived from the content and read-only. " +
|
|
26
|
+
"On a TEXT node, leave it out: the box then takes the exact height of the text (lines x lineHeight), " +
|
|
27
|
+
"so the first line starts at y. Set it only to centre a label inside a container — give the text node " +
|
|
28
|
+
"the container's y and height, and the block of lines is centred in it.",
|
|
26
29
|
rotation: "Rotation in degrees, clockwise, around the centre of the bounding box. 0 = upright.",
|
|
27
30
|
opacity: "Opacity, from 0 (fully transparent) to 1 (fully opaque).",
|
|
28
31
|
fill: 'CSS colour filling the interior, e.g. "#ffffff" or "rgba(0,0,0,0.4)". rect, ellipse and icon. On an icon it is "none" by default — the shape is drawn by its stroke.',
|
|
@@ -42,7 +45,8 @@ export const NODE_FIELD_DOCS = {
|
|
|
42
45
|
"node to read and to change. Whenever the same block appears more than once (cards, list rows, menu items, " +
|
|
43
46
|
"buttons), build it once as a group and add instances of it instead of duplicating it. " +
|
|
44
47
|
"Change this field to swap an instance over to another group; use detach_instances when one copy must diverge.",
|
|
45
|
-
text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line."
|
|
48
|
+
text: "Text content. It is NEVER auto-wrapped: only explicit \\n characters start a new line. " +
|
|
49
|
+
"A box that hugs its text keeps hugging it: adding a line grows the box instead of overflowing it.",
|
|
46
50
|
color: "CSS colour of the text.",
|
|
47
51
|
fontSize: "Font size, in pixels (same meaning as the CSS font-size property).",
|
|
48
52
|
fontFamily: 'CSS font stack, e.g. "Inter, system-ui, sans-serif".',
|
package/dist/shared/render.d.ts
CHANGED
|
@@ -81,6 +81,24 @@ export declare function textMetrics(node: RenderNode): {
|
|
|
81
81
|
};
|
|
82
82
|
/** Hauteur minimale nécessaire pour afficher tout le texte du noeud. */
|
|
83
83
|
export declare function measuredTextHeight(node: RenderNode): number;
|
|
84
|
+
/**
|
|
85
|
+
* Vrai quand la boîte épouse déjà exactement son texte.
|
|
86
|
+
*
|
|
87
|
+
* C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
|
|
88
|
+
* suit son contenu, et doit continuer à le suivre quand le texte change. Une
|
|
89
|
+
* hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
|
|
90
|
+
* cette égalité et n'est donc jamais réajustée.
|
|
91
|
+
*/
|
|
92
|
+
export declare function hugsText(node: RenderNode): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Le noeud, avec la hauteur qui épouse son texte.
|
|
95
|
+
*
|
|
96
|
+
* Une boîte à la taille de son texte le place de fait en haut à gauche : le
|
|
97
|
+
* centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
|
|
98
|
+
* texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
|
|
99
|
+
* lui, donne une hauteur explicite.
|
|
100
|
+
*/
|
|
101
|
+
export declare function withFittedTextHeight<T extends RenderNode>(node: T): T;
|
|
84
102
|
/**
|
|
85
103
|
* Transform SVG d'un groupe : translation vers son origine, puis rotation
|
|
86
104
|
* autour du centre de son contenu (exprime dans l'espace local du groupe).
|
package/dist/shared/render.js
CHANGED
|
@@ -98,6 +98,28 @@ export function measuredTextHeight(node) {
|
|
|
98
98
|
const { lineStep } = textMetrics(node);
|
|
99
99
|
return Math.max(lineStep, textLines(node).length * lineStep);
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Vrai quand la boîte épouse déjà exactement son texte.
|
|
103
|
+
*
|
|
104
|
+
* C'est le signe qu'aucune hauteur n'a été voulue pour elle-même : la boîte
|
|
105
|
+
* suit son contenu, et doit continuer à le suivre quand le texte change. Une
|
|
106
|
+
* hauteur choisie (un libellé calé sur la hauteur de son bouton) ne vérifie pas
|
|
107
|
+
* cette égalité et n'est donc jamais réajustée.
|
|
108
|
+
*/
|
|
109
|
+
export function hugsText(node) {
|
|
110
|
+
return Math.abs(Math.abs(node.height) - measuredTextHeight(node)) < 0.5;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Le noeud, avec la hauteur qui épouse son texte.
|
|
114
|
+
*
|
|
115
|
+
* Une boîte à la taille de son texte le place de fait en haut à gauche : le
|
|
116
|
+
* centrage vertical n'a plus rien à décaler. C'est ce qu'attend qui pose un
|
|
117
|
+
* texte sans penser à sa boîte ; un conteneur qui veut centrer son libellé,
|
|
118
|
+
* lui, donne une hauteur explicite.
|
|
119
|
+
*/
|
|
120
|
+
export function withFittedTextHeight(node) {
|
|
121
|
+
return { ...node, height: measuredTextHeight(node) };
|
|
122
|
+
}
|
|
101
123
|
function shapeAttributes(node) {
|
|
102
124
|
const parts = [];
|
|
103
125
|
if (node.type !== "line" && node.type !== "text") {
|
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment la GUI sera exposée. `undefined` des deux côtés = rien n'a été passé
|
|
3
|
+
* en ligne de commande : on retombe sur le placeholder PROXY_URL à compléter.
|
|
4
|
+
*/
|
|
5
|
+
export interface GuiExposure {
|
|
6
|
+
proxyUrl?: string;
|
|
7
|
+
appPort?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Traduit `--proxy_url=` / `--app_port=` en mode d'exposition de la GUI.
|
|
11
|
+
* Les deux ensemble sont refusés : au runtime PROXY_URL primerait toujours,
|
|
12
|
+
* donc un fichier portant les deux serait silencieusement trompeur.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseGuiExposure(argv: string[]): {
|
|
15
|
+
exposure: GuiExposure;
|
|
16
|
+
} | {
|
|
17
|
+
error: string;
|
|
18
|
+
};
|
|
1
19
|
export interface SetupMcpOptions {
|
|
2
20
|
cwd: string;
|
|
3
21
|
force: boolean;
|
|
22
|
+
exposure?: GuiExposure;
|
|
4
23
|
}
|
|
5
|
-
export declare function runSetupMcp({ cwd, force }: SetupMcpOptions): number;
|
|
24
|
+
export declare function runSetupMcp({ cwd, force, exposure }: SetupMcpOptions): number;
|
|
@@ -16,16 +16,20 @@ function toDisplayName(folder) {
|
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Valeurs volontairement vides : chaque variable est inactive tant qu'elle n'est
|
|
19
|
-
* pas remplie (PROXY_URL
|
|
20
|
-
* utilisable tel quel, jamais à moitié cassé.
|
|
19
|
+
* pas remplie (PROXY_URL et APP_PORT vides = GUI désactivée), donc le fichier
|
|
20
|
+
* généré est utilisable tel quel, jamais à moitié cassé.
|
|
21
21
|
*/
|
|
22
|
-
function buildServerEntry(codeRoot) {
|
|
22
|
+
function buildServerEntry(codeRoot, exposure) {
|
|
23
23
|
const folder = path.basename(codeRoot) || "design";
|
|
24
|
+
// Un port explicite désactive le placeholder proxy : les deux variables ne
|
|
25
|
+
// doivent jamais être renseignées ensemble, PROXY_URL primant au runtime.
|
|
26
|
+
const proxyUrl = exposure.appPort !== undefined ? "" : exposure.proxyUrl ?? "http://localhost:";
|
|
24
27
|
return {
|
|
25
28
|
command: "npx",
|
|
26
29
|
args: ["-y", PACKAGE_NAME],
|
|
27
30
|
env: {
|
|
28
|
-
PROXY_URL:
|
|
31
|
+
PROXY_URL: proxyUrl,
|
|
32
|
+
APP_PORT: exposure.appPort !== undefined ? String(exposure.appPort) : "",
|
|
29
33
|
APP_GROUP: capitalize(folder),
|
|
30
34
|
APP_NAME: `Design ${toDisplayName(folder)}`,
|
|
31
35
|
APP_PATH: `/design-${folder}`,
|
|
@@ -36,13 +40,60 @@ function buildServerEntry(codeRoot) {
|
|
|
36
40
|
}
|
|
37
41
|
const GENERATED_VARS = [
|
|
38
42
|
["PROXY_URL", "URL du proxy pour exposer la GUI (compléter le port)"],
|
|
43
|
+
["APP_PORT", "port local si aucun proxy — la GUI est servie sur http://localhost:<port>"],
|
|
39
44
|
["APP_GROUP", "groupe d'affichage dans le dashboard du proxy"],
|
|
40
45
|
["APP_NAME", "nom affiché de la GUI dans le dashboard du proxy"],
|
|
41
46
|
["APP_PATH", "chemin de montage de la GUI derrière le proxy"],
|
|
42
47
|
["MCP_DESIGN_DATA_DIR", "répertoire de stockage des designs (pré-rempli)"],
|
|
43
48
|
["MCP_LOG_DIR", "répertoire des logs (défaut : <data>/logs)"],
|
|
44
49
|
];
|
|
45
|
-
|
|
50
|
+
/** Lit `--flag=valeur` (tirets ou underscores acceptés). */
|
|
51
|
+
function readFlag(argv, name) {
|
|
52
|
+
const prefixes = [`--${name}=`, `--${name.replace(/_/g, "-")}=`];
|
|
53
|
+
for (const arg of argv) {
|
|
54
|
+
const prefix = prefixes.find((p) => arg.startsWith(p));
|
|
55
|
+
if (prefix)
|
|
56
|
+
return arg.slice(prefix.length).trim();
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Traduit `--proxy_url=` / `--app_port=` en mode d'exposition de la GUI.
|
|
62
|
+
* Les deux ensemble sont refusés : au runtime PROXY_URL primerait toujours,
|
|
63
|
+
* donc un fichier portant les deux serait silencieusement trompeur.
|
|
64
|
+
*/
|
|
65
|
+
export function parseGuiExposure(argv) {
|
|
66
|
+
const rawProxyUrl = readFlag(argv, "proxy_url");
|
|
67
|
+
const rawAppPort = readFlag(argv, "app_port");
|
|
68
|
+
if (rawProxyUrl !== undefined && rawAppPort !== undefined) {
|
|
69
|
+
return { error: "--proxy_url et --app_port sont exclusifs : la GUI passe par le proxy, ou écoute sur un port local." };
|
|
70
|
+
}
|
|
71
|
+
if (rawProxyUrl !== undefined) {
|
|
72
|
+
if (rawProxyUrl === "")
|
|
73
|
+
return { error: "--proxy_url attend une URL, ex : --proxy_url=http://localhost:3000" };
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = new URL(rawProxyUrl);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { error: `--proxy_url invalide : "${rawProxyUrl}" n'est pas une URL absolue (ex : http://localhost:3000).` };
|
|
80
|
+
}
|
|
81
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
82
|
+
return { error: `--proxy_url invalide : protocole "${parsed.protocol}" non supporté (http ou https attendu).` };
|
|
83
|
+
}
|
|
84
|
+
// Une barre finale casserait la concaténation PROXY_URL + APP_PATH.
|
|
85
|
+
return { exposure: { proxyUrl: rawProxyUrl.replace(/\/+$/, "") } };
|
|
86
|
+
}
|
|
87
|
+
if (rawAppPort !== undefined) {
|
|
88
|
+
const port = Number(rawAppPort);
|
|
89
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
90
|
+
return { error: `--app_port invalide : "${rawAppPort}" n'est pas un entier entre 1 et 65535.` };
|
|
91
|
+
}
|
|
92
|
+
return { exposure: { appPort: port } };
|
|
93
|
+
}
|
|
94
|
+
return { exposure: {} };
|
|
95
|
+
}
|
|
96
|
+
export function runSetupMcp({ cwd, force, exposure = {} }) {
|
|
46
97
|
const target = path.join(cwd, ".mcp.json");
|
|
47
98
|
let config = {};
|
|
48
99
|
const exists = fs.existsSync(target);
|
|
@@ -72,16 +123,23 @@ export function runSetupMcp({ cwd, force }) {
|
|
|
72
123
|
console.error("Relancez avec --force pour écraser cette entrée (les autres serveurs sont préservés).");
|
|
73
124
|
return 1;
|
|
74
125
|
}
|
|
75
|
-
config.mcpServers[SERVER_NAME] = buildServerEntry(cwd);
|
|
126
|
+
config.mcpServers[SERVER_NAME] = buildServerEntry(cwd, exposure);
|
|
76
127
|
fs.writeFileSync(target, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
77
128
|
const action = alreadyPresent ? "remplacée" : exists ? "ajoutée" : "créée";
|
|
78
129
|
console.log(`Configuration ${SERVER_NAME} ${action} dans ${target}`);
|
|
79
130
|
console.log("");
|
|
131
|
+
if (exposure.appPort !== undefined) {
|
|
132
|
+
console.log(`GUI en accès direct : http://localhost:${exposure.appPort} (sans proxy).`);
|
|
133
|
+
}
|
|
134
|
+
else if (exposure.proxyUrl !== undefined) {
|
|
135
|
+
console.log(`GUI exposée via le proxy ${exposure.proxyUrl}.`);
|
|
136
|
+
}
|
|
80
137
|
console.log('À compléter dans le champ "env" :');
|
|
81
138
|
for (const [name, description] of GENERATED_VARS) {
|
|
82
139
|
console.log(` ${name.padEnd(24)} ${description}`);
|
|
83
140
|
}
|
|
84
141
|
console.log("");
|
|
85
142
|
console.log("Une variable laissée vide est simplement inactive.");
|
|
143
|
+
console.log("PROXY_URL et APP_PORT s'excluent : PROXY_URL prime si les deux sont renseignés.");
|
|
86
144
|
return 0;
|
|
87
145
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DesignNodeSchema } from "../../shared/schemas/design.schema.js";
|
|
2
2
|
import { GROUP_DERIVED_FIELDS, NODE_DEFAULTS, NODE_GEOMETRY_FIELDS, NODE_STYLE_FIELDS, SIZE_DERIVED_KINDS, clampNodeSize, defaultNodeName, } from "../../shared/node-defaults.js";
|
|
3
|
+
import { hugsText, measuredTextHeight } from "../../shared/render.js";
|
|
3
4
|
import { CORE_ICONS, hasIcon, iconCount } from "../../shared/icons/index.js";
|
|
4
5
|
import { suggestIcons } from "../../shared/icons/search.js";
|
|
5
6
|
import { shortId } from "./ids.js";
|
|
@@ -106,6 +107,14 @@ export function buildNode(input, existingIds, index) {
|
|
|
106
107
|
const size = clampNodeSize(type, Number(draft.width), Number(draft.height));
|
|
107
108
|
draft.width = size.width;
|
|
108
109
|
draft.height = size.height;
|
|
110
|
+
// Sans hauteur demandée, la boîte d'un texte est celle de son texte : le bloc
|
|
111
|
+
// de lignes, centré dans une boîte à sa taille, se pose alors exactement au
|
|
112
|
+
// point donné — ce que l'appelant attend quand il n'a parlé que de x et y.
|
|
113
|
+
// Après le clamp, donc : ce plancher garde une boîte dessinée sélectionnable,
|
|
114
|
+
// il n'a pas à gonfler une boîte qui n'a jamais été dimensionnée à la souris.
|
|
115
|
+
if (type === "text" && input.height === undefined) {
|
|
116
|
+
draft.height = measuredTextHeight(draft);
|
|
117
|
+
}
|
|
109
118
|
const parsed = DesignNodeSchema.safeParse(draft);
|
|
110
119
|
if (!parsed.success) {
|
|
111
120
|
throw new DesignError(`Invalid node: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
|
|
@@ -131,6 +140,12 @@ export function applyPatch(node, patch) {
|
|
|
131
140
|
if (patch[field] !== undefined)
|
|
132
141
|
next[field] = patch[field];
|
|
133
142
|
}
|
|
143
|
+
// Une boîte qui épousait son texte continue de l'épouser : rallonger le texte
|
|
144
|
+
// d'une ligne agrandit la boîte au lieu de la faire déborder. Une hauteur
|
|
145
|
+
// choisie — ou redonnée dans ce patch — reste intacte.
|
|
146
|
+
if (node.type === "text" && patch.height === undefined && hugsText(node)) {
|
|
147
|
+
next.height = measuredTextHeight(next);
|
|
148
|
+
}
|
|
134
149
|
if (node.type === "icon" && patch.icon !== undefined)
|
|
135
150
|
next.icon = requireIconName(patch.icon);
|
|
136
151
|
// Changer le lien echange l'instance de composant, comme un "swap instance".
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -21,6 +21,29 @@ function findProjectRoot(startDir) {
|
|
|
21
21
|
const rootDir = findProjectRoot(__dirname);
|
|
22
22
|
const absoluteStaticRoot = path.resolve(path.join(rootDir, "gui/dist"));
|
|
23
23
|
const configuredAppPath = process.env.APP_PATH || "/design-gui";
|
|
24
|
+
/**
|
|
25
|
+
* Chemin réellement monté par le proxy. Il peut différer de `configuredAppPath`
|
|
26
|
+
* quand `APP_PATH_PREFIX` est défini : le client partagé préfixe alors le chemin
|
|
27
|
+
* naturel et renvoie le chemin final dans `result.path`. Le `<base href>` doit
|
|
28
|
+
* suivre ce chemin, sinon les assets de la SPA sont demandés au mauvais endroit.
|
|
29
|
+
*/
|
|
30
|
+
let effectiveAppPath = configuredAppPath;
|
|
31
|
+
function resolveGuiMode() {
|
|
32
|
+
const proxyUrl = process.env.PROXY_URL?.trim();
|
|
33
|
+
if (proxyUrl)
|
|
34
|
+
return { kind: "proxy", proxyUrl };
|
|
35
|
+
const rawPort = process.env.APP_PORT?.trim();
|
|
36
|
+
if (!rawPort) {
|
|
37
|
+
return { kind: "disabled", reason: "PROXY_URL et APP_PORT non définis" };
|
|
38
|
+
}
|
|
39
|
+
const port = Number(rawPort);
|
|
40
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
41
|
+
return { kind: "disabled", reason: `APP_PORT invalide (${rawPort}) — attendu : entier entre 1 et 65535` };
|
|
42
|
+
}
|
|
43
|
+
return { kind: "standalone", port };
|
|
44
|
+
}
|
|
45
|
+
const guiMode = resolveGuiMode();
|
|
46
|
+
const isStandalone = guiMode.kind === "standalone";
|
|
24
47
|
setupLogging({ processLabel: "mcp-design:GUI", logDir: resolveLogDir(rootDir) });
|
|
25
48
|
const logger = createLogger("mcp-design:GUI");
|
|
26
49
|
process.on("uncaughtException", (err) => {
|
|
@@ -136,7 +159,7 @@ app.get("/proxy/health", (c) => {
|
|
|
136
159
|
const memory = process.memoryUsage();
|
|
137
160
|
return c.json({
|
|
138
161
|
status: "ok",
|
|
139
|
-
proxy: proxyClient ? proxyClient.getStatus() :
|
|
162
|
+
proxy: proxyClient ? proxyClient.getStatus() : isStandalone ? "standalone" : "error",
|
|
140
163
|
uptime: process.uptime(),
|
|
141
164
|
memory: { rss: memory.rss, heapUsed: memory.heapUsed },
|
|
142
165
|
ipc: process.connected ? "connected" : "disconnected",
|
|
@@ -262,7 +285,8 @@ app.get("/*", (c, next) => {
|
|
|
262
285
|
if (fs.existsSync(indexPath)) {
|
|
263
286
|
// ?__direct=1 : accès au worker sur son port local, sans passer par le
|
|
264
287
|
// proxy — le préfixe de montage ne doit alors pas préfixer les assets.
|
|
265
|
-
|
|
288
|
+
// En mode standalone la GUI est déjà servie à la racine : même règle.
|
|
289
|
+
const baseHref = isStandalone || c.req.query("__direct") === "1" ? "/" : getHtmlBaseHref(effectiveAppPath);
|
|
266
290
|
const html = fs.readFileSync(indexPath, "utf8").replace("<head>", `<head>\n <base href="${baseHref}" />`);
|
|
267
291
|
return c.html(html);
|
|
268
292
|
}
|
|
@@ -272,12 +296,51 @@ app.get("/*", (c, next) => {
|
|
|
272
296
|
return next();
|
|
273
297
|
}
|
|
274
298
|
});
|
|
299
|
+
/**
|
|
300
|
+
* Lance Hono sur `port` et prévient le master. `publicUrl` est l'URL que
|
|
301
|
+
* l'utilisateur doit ouvrir : via le proxy, ou directement en localhost.
|
|
302
|
+
*/
|
|
303
|
+
function listen(port, publicUrl, isProxied) {
|
|
304
|
+
const serverInstance = serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
|
|
305
|
+
logger.info(`Design canvas server listening on port ${info.port}`);
|
|
306
|
+
safeSend(IPCMessageSchema.parse({
|
|
307
|
+
type: "READY",
|
|
308
|
+
data: { port: info.port, isProxied, url: publicUrl },
|
|
309
|
+
timestamp: new Date().toISOString(),
|
|
310
|
+
}));
|
|
311
|
+
});
|
|
312
|
+
serverInstance.on("error", (err) => {
|
|
313
|
+
if (err.code === "EADDRINUSE") {
|
|
314
|
+
// Sortie propre (code 0) : le master ne doit pas relancer un worker de plus.
|
|
315
|
+
logger.error(`Port ${port} already in use — another GUI instance is running.`);
|
|
316
|
+
safeSend(IPCMessageSchema.parse({
|
|
317
|
+
type: "ALREADY_RUNNING",
|
|
318
|
+
data: { url: publicUrl },
|
|
319
|
+
timestamp: new Date().toISOString(),
|
|
320
|
+
}));
|
|
321
|
+
process.exit(0);
|
|
322
|
+
}
|
|
323
|
+
logger.error(`Server error: ${err.message}`);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/** Sans proxy : la GUI écoute sur APP_PORT et est servie à la racine. */
|
|
328
|
+
function startStandalone(port) {
|
|
329
|
+
const publicUrl = `http://localhost:${port}`;
|
|
330
|
+
logger.info(`Standalone mode (no PROXY_URL): serving GUI on ${publicUrl}`);
|
|
331
|
+
setupIpcErrorHandlers();
|
|
332
|
+
listen(port, publicUrl, false);
|
|
333
|
+
}
|
|
275
334
|
async function startServer() {
|
|
276
|
-
if (
|
|
277
|
-
process.stderr.write(
|
|
335
|
+
if (guiMode.kind === "disabled") {
|
|
336
|
+
process.stderr.write(`[GUI] ${guiMode.reason} — arrêt.\n`);
|
|
278
337
|
process.exit(0);
|
|
279
338
|
}
|
|
280
|
-
|
|
339
|
+
if (guiMode.kind === "standalone") {
|
|
340
|
+
startStandalone(guiMode.port);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const proxyUrl = guiMode.proxyUrl;
|
|
281
344
|
const appPath = configuredAppPath;
|
|
282
345
|
const appName = process.env.APP_NAME || "MCP Design";
|
|
283
346
|
logger.info(`Starting server with proxyUrl=${proxyUrl}`);
|
|
@@ -311,7 +374,8 @@ async function startServer() {
|
|
|
311
374
|
return;
|
|
312
375
|
}
|
|
313
376
|
const finalPort = result.port;
|
|
314
|
-
|
|
377
|
+
effectiveAppPath = result.path ?? appPath;
|
|
378
|
+
const publicUrl = result.url || `${proxyUrl}${effectiveAppPath}`;
|
|
315
379
|
logger.info(`Registered with proxy. Public URL: ${publicUrl}, Local Port: ${finalPort}`);
|
|
316
380
|
const cleanupProxy = async () => {
|
|
317
381
|
if (proxyClient)
|
|
@@ -321,23 +385,7 @@ async function startServer() {
|
|
|
321
385
|
process.on("SIGTERM", cleanupProxy);
|
|
322
386
|
process.on("SIGINT", cleanupProxy);
|
|
323
387
|
setupIpcErrorHandlers();
|
|
324
|
-
|
|
325
|
-
logger.info(`Design canvas server listening on port ${info.port}`);
|
|
326
|
-
safeSend(IPCMessageSchema.parse({
|
|
327
|
-
type: "READY",
|
|
328
|
-
data: { port: info.port, isProxied: true, url: publicUrl },
|
|
329
|
-
timestamp: new Date().toISOString(),
|
|
330
|
-
}));
|
|
331
|
-
});
|
|
332
|
-
serverInstance.on("error", (err) => {
|
|
333
|
-
if (err.code === "EADDRINUSE") {
|
|
334
|
-
// Sortie propre (code 0) : le master ne doit pas relancer un worker de plus.
|
|
335
|
-
logger.error(`Port ${finalPort} already in use — another GUI instance is running.`);
|
|
336
|
-
process.exit(0);
|
|
337
|
-
}
|
|
338
|
-
logger.error(`Server error: ${err.message}`);
|
|
339
|
-
process.exit(1);
|
|
340
|
-
});
|
|
388
|
+
listen(finalPort, publicUrl, true);
|
|
341
389
|
}
|
|
342
390
|
startServer().catch((err) => {
|
|
343
391
|
logger.error(`Failed to start GUI Worker: ${err.message}`);
|