story-theme-mcp 1.4.0 → 1.5.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/package.json +1 -1
- package/src/server.js +1 -1
- package/src/shops/direct.js +187 -0
- package/src/shops/tools.js +5 -3
- package/src/version.js +1 -1
- package/src/shops/push.js +0 -198
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "story-theme-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Serveur MCP du Story Thème : votre assistant IA connaît le thème Shopify Story par cœur et construit des boutiques propres et professionnelles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/server.js
CHANGED
|
@@ -81,7 +81,7 @@ Quelques blocs qui reviennent souvent : group (imbriquable, horizontal ou vertic
|
|
|
81
81
|
|
|
82
82
|
## 5. Ses vraies boutiques, avant tout le reste
|
|
83
83
|
|
|
84
|
-
Appelez list_shops AVANT de construire. Si le marchand a installé l'application Story Apps, ses boutiques sont là : vous lisez ses vrais produits, collections et pages, et vous écrivez dedans (shop_graphql) sans aucune clé Shopify. Une boutique réelle bat toujours un contenu inventé — servez-vous-en pour les noms de produits, les prix, les collections, les photos déjà en ligne.
|
|
84
|
+
Appelez list_shops AVANT de construire. Il dit aussi quelles boutiques sont BRANCHÉES en direct : sur celles-là, vous écrivez dans un thème sans que le marchand importe quoi que ce soit — push_to_shop({ shops, create: true }) crée un thème non publié à partir du projet, et push_to_shop({ shops, theme }) écrit les retouches suivantes dedans. Le marchand prévisualise et publie lui-même ; le thème publié est refusé sauf demande explicite. Si aucune boutique n'est branchée, list_shops donne la marche à suivre (deux minutes, une seule fois) : proposez-la, c'est ce qui évite l'aller-retour par un zip à chaque modification. Si le marchand a installé l'application Story Apps, ses boutiques sont là : vous lisez ses vrais produits, collections et pages, et vous écrivez dedans (shop_graphql) sans aucune clé Shopify. Une boutique réelle bat toujours un contenu inventé — servez-vous-en pour les noms de produits, les prix, les collections, les photos déjà en ligne.
|
|
85
85
|
|
|
86
86
|
Le Story Thème affiche ; il ne crée ni produits, ni collections, ni pages, ni menus, ni réductions. Si list_shops ne renvoie rien, proposez au marchand d'installer Story Apps (apps.story-theme.com) ou de brancher un MCP Shopify à son assistant : vous pourrez alors créer les pages et leur attribuer les modèles, monter les menus, créer les réductions des offres, et déposer les photos dans Contenu > Fichiers. Sans l'un ni l'autre, listez ce qu'il doit faire à la main.
|
|
87
87
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// =====================================================================
|
|
2
|
+
// Écrire dans la boutique du marchand, sans zip
|
|
3
|
+
// =====================================================================
|
|
4
|
+
// Première tentative : l'API GraphQL des thèmes de Shopify. Refusée — `themeFilesUpsert` et
|
|
5
|
+
// `themeCreate` exigent une dérogation accordée au cas par cas, y compris à une application
|
|
6
|
+
// personnalisée (vérifié en production : ACCESS_DENIED).
|
|
7
|
+
//
|
|
8
|
+
// L'API REST, elle, ne restreint que les applications distribuées sur l'App Store. Le marchand
|
|
9
|
+
// crée donc une application personnalisée dans sa propre administration, colle son jeton une
|
|
10
|
+
// fois dans son espace membre, et tout passe ensuite par là (vérifié : PUT assets.json → 200).
|
|
11
|
+
//
|
|
12
|
+
// Le MCP ne voit jamais ce jeton : il parle à l'espace membre, qui détient les jetons chiffrés
|
|
13
|
+
// et applique les règles — boutiques du compte seulement, fichiers autorisés seulement, thème
|
|
14
|
+
// publié refusé sauf demande explicite.
|
|
15
|
+
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
const chemin = (config, suite) => `${String(config.apiBase || 'https://story-theme.com').replace(/\/+$/, '')}/api/v1/mcp${suite}`;
|
|
19
|
+
|
|
20
|
+
/** Un appel à l'espace membre, avec le jeton de la session. */
|
|
21
|
+
async function appel(config, suite, options = {}) {
|
|
22
|
+
if (!config.token) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'Aucun jeton : connectez le MCP à votre espace Story Thème (bouton « Se connecter » du connecteur), ou ajoutez STORY_MCP_TOKEN.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
let reponse;
|
|
28
|
+
try {
|
|
29
|
+
reponse = await fetch(chemin(config, suite), {
|
|
30
|
+
...options,
|
|
31
|
+
headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json', Accept: 'application/json', ...(options.headers || {}) },
|
|
32
|
+
signal: AbortSignal.timeout(120000),
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error('Espace Story Thème injoignable. Réessayez dans un instant.');
|
|
36
|
+
}
|
|
37
|
+
const data = await reponse.json().catch(() => ({}));
|
|
38
|
+
if (!reponse.ok) throw new Error(data.message || `Erreur ${reponse.status}`);
|
|
39
|
+
return data.data || {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const AIDE_BRANCHEMENT = [
|
|
43
|
+
'Pour brancher une boutique (une fois, deux minutes) :',
|
|
44
|
+
'1. Espace membre Story Thème > MCP & Appli > « Brancher une boutique ».',
|
|
45
|
+
'2. Le lien ouvre l’administration Shopify : Paramètres > Applications > Développer des applications > Créer une application.',
|
|
46
|
+
'3. Autorisations Admin : cochez write_themes et read_themes, puis installez.',
|
|
47
|
+
'4. Révélez le jeton (shpat_…) et collez-le dans l’espace membre.',
|
|
48
|
+
'Ensuite, tout s’écrit directement dans la boutique : plus aucun zip à importer.',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
51
|
+
export function registerDirectTools(server, config, { ctx, ok, garde }) {
|
|
52
|
+
server.registerTool(
|
|
53
|
+
'list_shops',
|
|
54
|
+
{
|
|
55
|
+
title: 'Vos boutiques',
|
|
56
|
+
description:
|
|
57
|
+
'Lists the Shopify stores declared in this Story Theme account, and says which ones are wired for direct writing. Call it before any store work.',
|
|
58
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
59
|
+
},
|
|
60
|
+
garde(async () => {
|
|
61
|
+
const { boutiques = [] } = await appel(config, '/boutiques');
|
|
62
|
+
if (!boutiques.length) {
|
|
63
|
+
return ok(`Aucune boutique déclarée dans ce compte Story Thème (espace membre > Boutiques).\n\n${AIDE_BRANCHEMENT}`, { boutiques });
|
|
64
|
+
}
|
|
65
|
+
const lignes = boutiques.map((b) => `${b.branchee ? '●' : '○'} ${b.nom} — ${b.domaine}${b.branchee ? '' : ' (non branchée)'}`);
|
|
66
|
+
const branchees = boutiques.filter((b) => b.branchee).length;
|
|
67
|
+
return ok(
|
|
68
|
+
[
|
|
69
|
+
`${boutiques.length} boutique(s) · ${branchees} branchée(s) en direct`,
|
|
70
|
+
'',
|
|
71
|
+
...lignes,
|
|
72
|
+
'',
|
|
73
|
+
branchees ? 'push_to_shop écrit directement dans une boutique branchée.' : '',
|
|
74
|
+
// Une seule boutique branchée sur trois, c'est deux marchands qui ignorent encore
|
|
75
|
+
// que c'est possible : la marche à suivre reste affichée tant qu'il en manque une.
|
|
76
|
+
branchees < boutiques.length ? `${branchees ? '\n' : ''}${AIDE_BRANCHEMENT}` : '',
|
|
77
|
+
]
|
|
78
|
+
.filter((ligne) => ligne !== '')
|
|
79
|
+
.join('\n'),
|
|
80
|
+
{ boutiques },
|
|
81
|
+
);
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
server.registerTool(
|
|
86
|
+
'push_to_shop',
|
|
87
|
+
{
|
|
88
|
+
title: 'Envoyer le projet dans une boutique',
|
|
89
|
+
description:
|
|
90
|
+
'Writes the project files (page templates, section groups, theme settings) straight into a theme of one connected store — no zip, no manual import. Without `theme`, lists the store themes. With `create: true`, creates a brand new unpublished theme from the project and writes nothing else. The published theme is refused unless confirm: true. Theme code is never touched.',
|
|
91
|
+
inputSchema: {
|
|
92
|
+
shops: z.string().describe('UNE boutique : son domaine ou son nom (list_shops les donne).'),
|
|
93
|
+
project: z.string().optional().describe('Projet à envoyer (par défaut : le projet en cours).'),
|
|
94
|
+
theme: z.string().optional().describe('Thème de destination : son identifiant numérique. Sans lui, la liste des thèmes est renvoyée.'),
|
|
95
|
+
create: z.boolean().optional().describe('Créer un thème NON PUBLIÉ à partir du projet, au lieu d’écrire dans un thème existant.'),
|
|
96
|
+
confirm: z.boolean().optional().describe('Obligatoire pour écrire dans le thème PUBLIÉ. Seulement après demande explicite du marchand.'),
|
|
97
|
+
},
|
|
98
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
99
|
+
},
|
|
100
|
+
garde(async ({ shops, project, theme, create, confirm }) => {
|
|
101
|
+
const cible = String(shops || '').trim();
|
|
102
|
+
if (!cible || cible === 'all') throw new Error('push_to_shop écrit dans UNE boutique à la fois : donnez son domaine (list_shops).');
|
|
103
|
+
|
|
104
|
+
const { store, catalog } = ctx.get();
|
|
105
|
+
const slug = store.resolve(project);
|
|
106
|
+
const meta = store.meta(slug);
|
|
107
|
+
const encode = encodeURIComponent(cible);
|
|
108
|
+
|
|
109
|
+
// Créer un thème : l'espace membre fabrique le zip, Shopify vient le chercher.
|
|
110
|
+
if (create) {
|
|
111
|
+
if (typeof config.publishExport !== 'function') {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'La création de thème demande que le MCP soit servi par l’espace membre (connecteur). En local, exportez le zip (export_theme) et importez-le une fois.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const { exportTheme } = await import('../project/export.js');
|
|
117
|
+
const resultat = exportTheme({ catalog, store, slug });
|
|
118
|
+
if (!resultat.ok) throw new Error(resultat.message);
|
|
119
|
+
// Le zip part sur notre stockage : c'est de là que Shopify viendra le chercher, par un
|
|
120
|
+
// lien signé que l'espace membre fabrique lui-même. Il ne passe jamais par l'assistant.
|
|
121
|
+
await config.publishExport({ zip: resultat.zip, checklist: resultat.checklist, project: slug, catalog: meta.theme?.version });
|
|
122
|
+
const fichier = resultat.zip.split('/').pop();
|
|
123
|
+
|
|
124
|
+
const { theme: cree } = await appel(config, `/boutiques/${encode}/themes`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
body: JSON.stringify({ nom: meta.name || slug, projet: slug, fichier }),
|
|
127
|
+
});
|
|
128
|
+
return ok(
|
|
129
|
+
[
|
|
130
|
+
`Thème « ${cree.nom} » créé sur ${cible}, en NON PUBLIÉ (identifiant ${cree.id}).`,
|
|
131
|
+
cree.traitement ? 'Shopify le décompresse : comptez une minute avant qu’il soit consultable.' : '',
|
|
132
|
+
'',
|
|
133
|
+
`Prévisualiser : https://${cible}/?preview_theme_id=${cree.id}`,
|
|
134
|
+
'Le marchand vérifie chaque page, puis publie lui-même (Boutique en ligne > Thèmes).',
|
|
135
|
+
'Les retouches suivantes iront directement dedans : push_to_shop avec son identifiant.',
|
|
136
|
+
]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join('\n'),
|
|
139
|
+
{ theme: cree },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Sans thème : on montre ce qui existe, le marchand choisit.
|
|
144
|
+
if (!theme) {
|
|
145
|
+
const { themes = [], domaine } = await appel(config, `/boutiques/${encode}/themes`);
|
|
146
|
+
return ok(
|
|
147
|
+
[
|
|
148
|
+
`Thèmes de ${domaine || cible} :`,
|
|
149
|
+
...themes.map((t) => `- ${t.nom} — identifiant ${t.id}${t.role === 'main' ? ' — PUBLIÉ (la vitrine actuelle)' : ''}`),
|
|
150
|
+
'',
|
|
151
|
+
`Écrire dedans : push_to_shop({ shops: "${cible}", theme: "<identifiant>" }).`,
|
|
152
|
+
`Créer un thème neuf à la place : push_to_shop({ shops: "${cible}", create: true }).`,
|
|
153
|
+
'Choisissez un thème NON publié : le marchand prévisualise, puis publie lui-même.',
|
|
154
|
+
].join('\n'),
|
|
155
|
+
{ themes },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Les fichiers du projet, et eux seuls : le serveur refuse tout le reste de toute façon.
|
|
160
|
+
const fichiers = [...store.effectiveFiles(slug)]
|
|
161
|
+
.filter(([, entree]) => entree.origin === 'project')
|
|
162
|
+
.map(([rel, entree]) => ({ chemin: rel, contenu: `${JSON.stringify(entree.data, null, 2)}\n` }));
|
|
163
|
+
if (!fichiers.length) throw new Error(`Le projet « ${slug} » n’a aucun fichier personnalisé : construisez la boutique d’abord.`);
|
|
164
|
+
|
|
165
|
+
const resultat = await appel(config, `/boutiques/${encode}/themes/${encodeURIComponent(theme)}`, {
|
|
166
|
+
method: 'PUT',
|
|
167
|
+
body: JSON.stringify({ fichiers, confirm: confirm === true }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const exclus = store.excluded(slug);
|
|
171
|
+
return ok(
|
|
172
|
+
[
|
|
173
|
+
`${resultat.ecrits.length} fichier(s) écrits dans « ${resultat.theme.nom} » sur ${resultat.domaine}${resultat.theme.role === 'main' ? ' — THÈME PUBLIÉ, la boutique a changé' : ''}.`,
|
|
174
|
+
resultat.refuses?.length ? `Refusés : ${resultat.refuses.join(' · ')}` : '',
|
|
175
|
+
'',
|
|
176
|
+
`Prévisualiser : ${resultat.apercu}`,
|
|
177
|
+
resultat.theme.role === 'main' ? '' : 'Le marchand vérifie sur ordinateur et sur mobile, puis publie lui-même.',
|
|
178
|
+
exclus.length ? `Modèles exclus du projet mais toujours présents dans ce thème : ${exclus.join(', ')}.` : '',
|
|
179
|
+
'Images, produits, collections, menus et réductions restent à créer côté Shopify : review_project en donne la liste.',
|
|
180
|
+
]
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.join('\n'),
|
|
183
|
+
resultat,
|
|
184
|
+
);
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
package/src/shops/tools.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { createShopsClient, appsBase, ShopsError } from './client.js';
|
|
10
10
|
import { formatShopList, formatOverview, formatGraphql } from './format.js';
|
|
11
|
-
import {
|
|
11
|
+
import { registerDirectTools } from './direct.js';
|
|
12
12
|
|
|
13
13
|
const ok = (text, structured) => ({ content: [{ type: 'text', text }], ...(structured ? { structuredContent: structured } : {}) });
|
|
14
14
|
const fail = (text) => ({ content: [{ type: 'text', text: `⛔ ${text}` }], isError: true });
|
|
@@ -28,7 +28,9 @@ const cibleSchema = z
|
|
|
28
28
|
export function registerShopTools(server, config, { fetchImpl, ctx } = {}) {
|
|
29
29
|
const client = createShopsClient({ token: config.token, base: appsBase(), fetchImpl });
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// `list_shops` est servi par direct.js quand un projet est disponible : il dit en plus
|
|
32
|
+
// quelles boutiques sont branchées pour l'écriture directe. Ici, c'est le repli.
|
|
33
|
+
if (!ctx) server.registerTool(
|
|
32
34
|
'list_shops',
|
|
33
35
|
{
|
|
34
36
|
title: 'Vos boutiques',
|
|
@@ -77,5 +79,5 @@ export function registerShopTools(server, config, { fetchImpl, ctx } = {}) {
|
|
|
77
79
|
);
|
|
78
80
|
|
|
79
81
|
// L'envoi direct suppose un projet sous la main : seul le serveur complet passe `ctx`.
|
|
80
|
-
if (ctx)
|
|
82
|
+
if (ctx) registerDirectTools(server, config, { ctx, ok, garde });
|
|
81
83
|
}
|
package/src/version.js
CHANGED
package/src/shops/push.js
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
// =====================================================================
|
|
2
|
-
// Envoyer un projet dans une boutique, sans repasser par un zip
|
|
3
|
-
// =====================================================================
|
|
4
|
-
// Jusqu'ici, chaque retouche coûtait quatre gestes au marchand : exporter, télécharger,
|
|
5
|
-
// importer dans Shopify, republier. Avec l'appli Story Apps déjà installée sur la boutique,
|
|
6
|
-
// les fichiers que le MCP possède — modèles, groupes de sections, réglages du thème —
|
|
7
|
-
// s'écrivent directement dans un thème de sa bibliothèque.
|
|
8
|
-
//
|
|
9
|
-
// Écrire dans une boutique en ligne ne se rattrape pas. Quatre garde-fous, dans cet ordre :
|
|
10
|
-
//
|
|
11
|
-
// 1. une seule boutique à la fois : « all » est refusé ;
|
|
12
|
-
// 2. jamais le thème PUBLIÉ sans confirm: true — la vitrine changerait à la seconde ;
|
|
13
|
-
// 3. jamais un thème qui n'est pas un Story Thème, ni une version majeure différente : nos
|
|
14
|
-
// modèles citent des sections qui n'existeraient pas en face, la boutique afficherait
|
|
15
|
-
// des cadres en erreur ;
|
|
16
|
-
// 4. seulement les fichiers du projet. Le code du thème (Liquid, CSS, JS) n'est jamais
|
|
17
|
-
// touché — ici comme partout ailleurs dans le MCP.
|
|
18
|
-
//
|
|
19
|
-
// Ce qui n'est PAS fait : supprimer des fichiers à distance. Un modèle exclu du projet reste
|
|
20
|
-
// dans le thème de la boutique ; on le dit plutôt que de l'effacer sans qu'on l'ait demandé.
|
|
21
|
-
|
|
22
|
-
import { z } from 'zod';
|
|
23
|
-
import { ShopsError } from './client.js';
|
|
24
|
-
|
|
25
|
-
// Shopify accepte davantage de fichiers par appel, mais un lot court échoue proprement : on
|
|
26
|
-
// sait quels modèles sont passés, et un dépassement de taille ne fait pas tout retomber.
|
|
27
|
-
const FICHIERS_PAR_LOT = 20;
|
|
28
|
-
|
|
29
|
-
const REQUETE_THEMES = `query StoryMcpThemes { themes(first: 50) { nodes { id name role } } }`;
|
|
30
|
-
|
|
31
|
-
const REQUETE_MARQUEUR = `query StoryMcpThemeCheck($id: ID!) {
|
|
32
|
-
theme(id: $id) {
|
|
33
|
-
id
|
|
34
|
-
name
|
|
35
|
-
role
|
|
36
|
-
files(filenames: ["config/settings_schema.json"], first: 1) {
|
|
37
|
-
nodes { body { ... on OnlineStoreThemeFileBodyText { content } } }
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}`;
|
|
41
|
-
|
|
42
|
-
const MUTATION_PUSH = `mutation StoryMcpPush($themeId: ID!, $files: [OnlineStoreThemeFilesUpsertFileInput!]!) {
|
|
43
|
-
themeFilesUpsert(themeId: $themeId, files: $files) {
|
|
44
|
-
upsertedThemeFiles { filename }
|
|
45
|
-
userErrors { filename code message }
|
|
46
|
-
}
|
|
47
|
-
}`;
|
|
48
|
-
|
|
49
|
-
/** Le résultat de LA boutique visée, ou une erreur en français. */
|
|
50
|
-
function resultatUnique(reponse, cible) {
|
|
51
|
-
const resultat = reponse.results?.[0];
|
|
52
|
-
if (!resultat) throw new ShopsError(`Boutique « ${cible} » introuvable : appelez list_shops pour voir les boutiques connectées.`);
|
|
53
|
-
if (resultat.errors?.length) {
|
|
54
|
-
const message = resultat.errors.map((erreur) => erreur.message).join(' · ');
|
|
55
|
-
// L'erreur de scope est la plus probable, et la plus opaque telle que Shopify la rend.
|
|
56
|
-
if (/write_themes|access denied|not approved/i.test(message)) {
|
|
57
|
-
throw new ShopsError(
|
|
58
|
-
"Story Apps n'a pas encore le droit d'écrire dans les thèmes de cette boutique. Ouvrez l'application Story Thème dans l'administration Shopify, page « Thème », et acceptez la demande d'autorisation (write_themes). Puis relancez push_to_shop.",
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
throw new ShopsError(message);
|
|
62
|
-
}
|
|
63
|
-
return resultat;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const numeroDe = (gid) => String(gid || '').split('/').pop();
|
|
67
|
-
|
|
68
|
-
/** La version majeure du thème : 4.1.2 et 4.0.0 sont compatibles, 3.x et 4.x ne le sont pas. */
|
|
69
|
-
const majeure = (version) => String(version || '').split('.')[0] || '';
|
|
70
|
-
|
|
71
|
-
export function registerPushTool(server, { client, ctx, ok, garde }) {
|
|
72
|
-
server.registerTool(
|
|
73
|
-
'push_to_shop',
|
|
74
|
-
{
|
|
75
|
-
title: 'Envoyer le projet dans une boutique',
|
|
76
|
-
description:
|
|
77
|
-
'Writes the project files (page templates, section groups, theme settings) straight into a theme of one connected store — no zip, no manual import. The store must already run a Story Theme: export and import the zip once, then every later iteration goes through this tool. Without a theme argument, lists the store themes so the merchant can pick an unpublished one. The published theme is refused unless confirm: true. Theme code is never touched, and nothing is ever deleted remotely.',
|
|
78
|
-
inputSchema: {
|
|
79
|
-
shops: z.string().describe('UNE boutique : son handle ou son domaine (list_shops les donne). « all » est refusé.'),
|
|
80
|
-
project: z.string().optional().describe('Projet à envoyer (par défaut : le projet en cours).'),
|
|
81
|
-
theme: z
|
|
82
|
-
.string()
|
|
83
|
-
.optional()
|
|
84
|
-
.describe('Thème de destination : son identifiant numérique ou son nom exact. Sans lui, la liste des thèmes de la boutique est renvoyée.'),
|
|
85
|
-
confirm: z
|
|
86
|
-
.boolean()
|
|
87
|
-
.optional()
|
|
88
|
-
.describe('Obligatoire pour écrire dans le thème PUBLIÉ : la boutique en ligne change immédiatement. Seulement après demande explicite du marchand.'),
|
|
89
|
-
},
|
|
90
|
-
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
91
|
-
},
|
|
92
|
-
garde(async ({ shops, project, theme, confirm }) => {
|
|
93
|
-
const cible = String(shops || '').trim();
|
|
94
|
-
if (!cible || cible === 'all') {
|
|
95
|
-
throw new ShopsError('push_to_shop écrit dans UNE boutique à la fois : donnez son handle ou son domaine (list_shops).');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const { store } = ctx.get();
|
|
99
|
-
const slug = store.resolve(project);
|
|
100
|
-
const meta = store.meta(slug);
|
|
101
|
-
|
|
102
|
-
// 1. Les thèmes de la boutique.
|
|
103
|
-
const liste = resultatUnique(await client.graphql({ shops: cible, query: REQUETE_THEMES }), cible);
|
|
104
|
-
const nomBoutique = liste.name || cible;
|
|
105
|
-
const themes = liste.data?.themes?.nodes || [];
|
|
106
|
-
|
|
107
|
-
if (!theme) {
|
|
108
|
-
return ok(
|
|
109
|
-
[
|
|
110
|
-
`Thèmes de ${nomBoutique} :`,
|
|
111
|
-
...themes.map((entree) => `- ${entree.name} — identifiant ${numeroDe(entree.id)}${entree.role === 'MAIN' ? ' — PUBLIÉ (la vitrine actuelle)' : ''}`),
|
|
112
|
-
'',
|
|
113
|
-
`Relancez : push_to_shop({ shops: "${cible}", theme: "<identifiant ou nom>" }).`,
|
|
114
|
-
'Choisissez un thème NON publié : le marchand prévisualise, vérifie, puis publie lui-même.',
|
|
115
|
-
"Si la boutique n'a pas encore de Story Thème dans sa bibliothèque, faites export_theme et importez le zip une seule fois — les envois suivants passeront par ici.",
|
|
116
|
-
].join('\n'),
|
|
117
|
-
{ shop: liste.shop, themes },
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const trouve = themes.find((entree) => entree.id === theme || numeroDe(entree.id) === String(theme) || entree.name === theme);
|
|
122
|
-
if (!trouve) {
|
|
123
|
-
throw new ShopsError(
|
|
124
|
-
`Thème « ${theme} » introuvable sur ${nomBoutique}. Thèmes disponibles : ${themes.map((entree) => `${entree.name} (${numeroDe(entree.id)})`).join(', ') || 'aucun'}.`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// 2. Le thème publié : seulement si le marchand l'a demandé.
|
|
129
|
-
if (trouve.role === 'MAIN' && confirm !== true) {
|
|
130
|
-
throw new ShopsError(
|
|
131
|
-
`« ${trouve.name} » est le thème PUBLIÉ de ${nomBoutique} : la boutique en ligne changerait immédiatement, sous les yeux de ses visiteurs. Choisissez un thème non publié, ou relancez avec confirm: true si le marchand vous l'a explicitement demandé.`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// 3. Un Story Thème, et de la même version majeure que le projet.
|
|
136
|
-
const marqueur = resultatUnique(await client.graphql({ shops: cible, query: REQUETE_MARQUEUR, variables: { id: trouve.id } }), cible);
|
|
137
|
-
const schema = marqueur.data?.theme?.files?.nodes?.[0]?.body?.content || '';
|
|
138
|
-
if (!/"theme_name"\s*:\s*"[^"]*Story Theme/i.test(schema)) {
|
|
139
|
-
throw new ShopsError(
|
|
140
|
-
`« ${trouve.name} » n'est pas un Story Thème : y écrire nos modèles afficherait des sections inexistantes sur toutes les pages. Importez d'abord le Story Thème (export_theme), puis relancez.`,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
const versionBoutique = schema.match(/"theme_version"\s*:\s*"([^"]+)"/i)?.[1] || '';
|
|
144
|
-
const versionProjet = meta.theme?.version || '';
|
|
145
|
-
if (majeure(versionBoutique) && majeure(versionProjet) && majeure(versionBoutique) !== majeure(versionProjet)) {
|
|
146
|
-
throw new ShopsError(
|
|
147
|
-
`« ${trouve.name} » est en Story Thème ${versionBoutique}, le projet a été construit sur la ${versionProjet}. Les sections ont changé entre ces versions : mettez le thème de la boutique à jour (application Story Thème, page « Thème »), ou migrez le projet (migrate_project), puis relancez.`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// 4. Les fichiers du projet, et eux seuls.
|
|
152
|
-
const fichiers = [...store.effectiveFiles(slug)]
|
|
153
|
-
.filter(([, entree]) => entree.origin === 'project')
|
|
154
|
-
.map(([rel, entree]) => ({ filename: rel, body: { type: 'TEXT', value: `${JSON.stringify(entree.data, null, 2)}\n` } }));
|
|
155
|
-
if (!fichiers.length) {
|
|
156
|
-
throw new ShopsError(
|
|
157
|
-
`Le projet « ${slug} » n'a encore aucun fichier personnalisé : il n'y a rien à envoyer. Construisez la boutique (build_store) d'abord.`,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const envoyes = [];
|
|
162
|
-
const refuses = [];
|
|
163
|
-
for (let debut = 0; debut < fichiers.length; debut += FICHIERS_PAR_LOT) {
|
|
164
|
-
const lot = fichiers.slice(debut, debut + FICHIERS_PAR_LOT);
|
|
165
|
-
// confirm: true — la passerelle ne le réclame que pour plusieurs boutiques, et il n'y en
|
|
166
|
-
// a qu'une ici ; on le passe pour que l'intention soit explicite dans son journal.
|
|
167
|
-
const reponse = resultatUnique(
|
|
168
|
-
await client.graphql({ shops: cible, query: MUTATION_PUSH, variables: { themeId: trouve.id, files: lot }, confirm: true }),
|
|
169
|
-
cible,
|
|
170
|
-
);
|
|
171
|
-
const bloc = reponse.data?.themeFilesUpsert;
|
|
172
|
-
envoyes.push(...(bloc?.upsertedThemeFiles || []).map((fichier) => fichier.filename));
|
|
173
|
-
refuses.push(...(bloc?.userErrors || []).map((erreur) => `${erreur.filename || '(fichier)'} : ${erreur.message}`));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const domaine = liste.shop || cible;
|
|
177
|
-
const exclus = store.excluded(slug);
|
|
178
|
-
return ok(
|
|
179
|
-
[
|
|
180
|
-
`${envoyes.length} fichier(s) écrits dans « ${trouve.name} » sur ${nomBoutique}${trouve.role === 'MAIN' ? ' — THÈME PUBLIÉ, la boutique a changé' : ''}.`,
|
|
181
|
-
refuses.length ? `Refusés par Shopify : ${refuses.join(' · ')}` : '',
|
|
182
|
-
'',
|
|
183
|
-
`Prévisualiser : https://${domaine}/?preview_theme_id=${numeroDe(trouve.id)}`,
|
|
184
|
-
trouve.role === 'MAIN'
|
|
185
|
-
? ''
|
|
186
|
-
: 'Le marchand vérifie chaque page sur ordinateur et sur mobile, puis publie lui-même (Boutique en ligne > Thèmes).',
|
|
187
|
-
exclus.length
|
|
188
|
-
? `Modèles exclus du projet, mais toujours présents dans ce thème (rien n'est supprimé à distance) : ${exclus.join(', ')}.`
|
|
189
|
-
: '',
|
|
190
|
-
'Images, produits, collections, menus et réductions restent à créer côté Shopify : review_project en donne la liste.',
|
|
191
|
-
]
|
|
192
|
-
.filter(Boolean)
|
|
193
|
-
.join('\n'),
|
|
194
|
-
{ shop: domaine, theme: { id: trouve.id, name: trouve.name, role: trouve.role }, written: envoyes, errors: refuses },
|
|
195
|
-
);
|
|
196
|
-
}),
|
|
197
|
-
);
|
|
198
|
-
}
|