Penpot MCP: gotchas, template script, workflow documenté
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Script tout-en-un : redémarre le MCP + crée l'affiche SOLDES
|
||||
* Paramètres MCP = camelCase !
|
||||
*/
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
|
||||
const PORTAINER = { host: '192.168.1.150', port: 9443 };
|
||||
const MCP = { host: '192.168.1.150', port: 9002 };
|
||||
const API_KEY = process.env.PORTAINER_API_KEY;
|
||||
|
||||
// ─── PORTAINER ───────────────────────────────────────────────────────────────
|
||||
function portainerReq(path, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { 'X-API-Key': API_KEY, 'Content-Length': 0 };
|
||||
const req = https.request({ ...PORTAINER, path, method, rejectUnauthorized: false, headers }, res => {
|
||||
let d = ''; res.on('data', c => d += c);
|
||||
res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(res.statusCode); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function restartMCPContainer() {
|
||||
const containers = await portainerReq('/api/endpoints/2/docker/containers/json?filters=%7B%22name%22%3A%5B%22penpot-penpot-mcp%22%5D%7D');
|
||||
const id = containers[0]?.Id;
|
||||
await portainerReq(`/api/endpoints/2/docker/containers/${id}/restart`, 'POST');
|
||||
console.log('🔄 MCP restarted');
|
||||
}
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ─── MCP CLIENT ──────────────────────────────────────────────────────────────
|
||||
let sessionId = null;
|
||||
let msgId = 1;
|
||||
|
||||
function postMCP(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
};
|
||||
if (sessionId) headers['mcp-session-id'] = sessionId;
|
||||
const req = http.request({ ...MCP, path: '/mcp', method: 'POST', headers }, res => {
|
||||
if (res.headers['mcp-session-id']) sessionId = res.headers['mcp-session-id'];
|
||||
let data = ''; res.setEncoding('utf8');
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
const ct = res.headers['content-type'] || '';
|
||||
if (ct.includes('text/event-stream')) {
|
||||
let result = null, error = null;
|
||||
for (const line of data.split('\n')) {
|
||||
if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
if (!raw || raw === '[DONE]') continue;
|
||||
try {
|
||||
const j = JSON.parse(raw);
|
||||
if (j.result !== undefined) result = j.result;
|
||||
if (j.error) error = j.error;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (error) reject(new Error(JSON.stringify(error)));
|
||||
else resolve(result);
|
||||
} else {
|
||||
try {
|
||||
const j = JSON.parse(data);
|
||||
if (j.error) reject(new Error(JSON.stringify(j.error)));
|
||||
else resolve(j.result ?? j);
|
||||
} catch { resolve(data || null); }
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject); req.write(payload); req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function initMCP() {
|
||||
await postMCP({ jsonrpc: '2.0', id: msgId++, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'nox', version: '1.0' } } });
|
||||
await postMCP({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }).catch(() => {});
|
||||
console.log('✅ MCP Session:', sessionId);
|
||||
}
|
||||
|
||||
async function callTool(name, args = {}) {
|
||||
const result = await postMCP({ jsonrpc: '2.0', id: msgId++, method: 'tools/call', params: { name, arguments: args } });
|
||||
if (!result) return null;
|
||||
// Le tool retourne content[0] (summary) et content[1] (JSON)
|
||||
if (result.content) {
|
||||
const jsonContent = result.content.find(c => { try { JSON.parse(c.text); return true; } catch { return false; } });
|
||||
if (jsonContent) { try { return JSON.parse(jsonContent.text); } catch {} }
|
||||
return result.content[0]?.text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function shape(type, args) {
|
||||
const r = await callTool(`create_${type}`, args);
|
||||
const id = typeof r === 'object' ? r?.id : null;
|
||||
console.log(` ✓ ${type} "${args.name || ''}" ${id ? '→ ' + id.slice(0,8) : JSON.stringify(r).slice(0,60)}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ─── MAIN ────────────────────────────────────────────────────────────────────
|
||||
console.log('🚀 Démarrage création affiche SOLDES...');
|
||||
await restartMCPContainer();
|
||||
await sleep(10000);
|
||||
await initMCP();
|
||||
|
||||
// Profil
|
||||
const profile = await callTool('get_profile', {});
|
||||
const projectId = profile?.defaultProjectId;
|
||||
console.log('ProjectId:', projectId);
|
||||
|
||||
// Créer le fichier (camelCase!)
|
||||
console.log('\n📄 Création du fichier...');
|
||||
const file = await callTool('create_file', { projectId, name: 'Affiche SOLDES -50%' });
|
||||
console.log('File:', JSON.stringify(file).slice(0, 150));
|
||||
|
||||
const fileId = file?.id;
|
||||
if (!fileId) { console.error('❌ Pas de fileId'); process.exit(1); }
|
||||
console.log('FileId:', fileId);
|
||||
|
||||
// Lister les pages (camelCase)
|
||||
const pagesResult = await callTool('list_pages', { fileId });
|
||||
console.log('Pages raw:', JSON.stringify(pagesResult).slice(0, 200));
|
||||
let pageId = null;
|
||||
if (Array.isArray(pagesResult)) pageId = pagesResult[0]?.id;
|
||||
else if (pagesResult?.pages) pageId = pagesResult.pages[0]?.id;
|
||||
else {
|
||||
// Extraire UUID depuis texte
|
||||
const uuids = JSON.stringify(pagesResult).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g);
|
||||
pageId = uuids?.find(u => u !== fileId);
|
||||
}
|
||||
console.log('PageId:', pageId);
|
||||
if (!pageId) { console.error('❌ Pas de pageId'); process.exit(1); }
|
||||
|
||||
// ─── DESIGN (600×900) ────────────────────────────────────────────────────────
|
||||
console.log('\n🎨 Construction du design...');
|
||||
|
||||
// Frame principale - fond rouge
|
||||
const mainFrame = await shape('frame', {
|
||||
fileId, pageId,
|
||||
name: 'Affiche SOLDES',
|
||||
x: 100, y: 100,
|
||||
width: 600, height: 900,
|
||||
fillColor: '#C0392B',
|
||||
});
|
||||
const frameId = mainFrame?.id;
|
||||
|
||||
// Bandes jaunes déco haut/bas
|
||||
await shape('rectangle', { fileId, pageId, parentId: frameId, name: 'Bande haut', x: 0, y: 0, width: 600, height: 14, fillColor: '#F1C40F' });
|
||||
await shape('rectangle', { fileId, pageId, parentId: frameId, name: 'Bande bas', x: 0, y: 886, width: 600, height: 14, fillColor: '#F1C40F' });
|
||||
|
||||
// Header sombre
|
||||
await shape('rectangle', { fileId, pageId, parentId: frameId, name: 'Header bg', x: 0, y: 14, width: 600, height: 88, fillColor: '#922B21' });
|
||||
|
||||
// MEGA SOLDES
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'MEGA SOLDES', content: 'MEGA SOLDES',
|
||||
x: 0, y: 28, width: 600, height: 65,
|
||||
fontSize: 36, fontWeight: '800', fillColor: '#F1C40F', align: 'center',
|
||||
});
|
||||
|
||||
// -50%
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: '-50%', content: '-50%',
|
||||
x: 0, y: 110, width: 600, height: 250,
|
||||
fontSize: 180, fontWeight: '900', fillColor: '#FFFFFF', align: 'center',
|
||||
});
|
||||
|
||||
// Sous-titre chaussures
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'Sous-titre', content: 'SUR TOUTES NOS CHAUSSURES',
|
||||
x: 0, y: 368, width: 600, height: 55,
|
||||
fontSize: 24, fontWeight: '700', fillColor: '#F1C40F', align: 'center',
|
||||
});
|
||||
|
||||
// Séparateur
|
||||
await shape('rectangle', { fileId, pageId, parentId: frameId, name: 'Sep', x: 80, y: 432, width: 440, height: 3, fillColor: '#F1C40F' });
|
||||
|
||||
// Badge offre
|
||||
await shape('rectangle', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'Badge bg', x: 125, y: 450, width: 350, height: 70,
|
||||
fillColor: '#F1C40F', r1: 12, r2: 12, r3: 12, r4: 12,
|
||||
});
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'Badge text', content: '⚡ OFFRE LIMITÉE ⚡',
|
||||
x: 125, y: 465, width: 350, height: 42,
|
||||
fontSize: 22, fontWeight: '800', fillColor: '#922B21', align: 'center',
|
||||
});
|
||||
|
||||
// Dates
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'Dates', content: 'du 1er au 31 mars 2026',
|
||||
x: 0, y: 540, width: 600, height: 40,
|
||||
fontSize: 20, fontWeight: '400', fillColor: '#FADBD8', align: 'center',
|
||||
});
|
||||
|
||||
// Emojis chaussures
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'Chaussures', content: '👟 👠 👞',
|
||||
x: 0, y: 590, width: 600, height: 160,
|
||||
fontSize: 90, align: 'center',
|
||||
});
|
||||
|
||||
// Ligne déco
|
||||
await shape('rectangle', { fileId, pageId, parentId: frameId, name: 'Sep2', x: 80, y: 760, width: 440, height: 3, fillColor: '#F8C471' });
|
||||
|
||||
// Site web
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'Site web', content: 'www.votre-boutique.fr',
|
||||
x: 0, y: 775, width: 600, height: 35,
|
||||
fontSize: 16, fontWeight: '400', fillColor: '#F8C471', align: 'center',
|
||||
});
|
||||
|
||||
// Étoiles déco bas
|
||||
await shape('text', {
|
||||
fileId, pageId, parentId: frameId,
|
||||
name: 'Etoiles', content: '★ ★ ★ ★ ★',
|
||||
x: 0, y: 830, width: 600, height: 40,
|
||||
fontSize: 22, fillColor: '#F1C40F', align: 'center',
|
||||
});
|
||||
|
||||
console.log('\n✅ Affiche SOLDES créée dans Penpot !');
|
||||
console.log('🔗 http://192.168.1.150:9001 → dossier "Drafts" → "Affiche SOLDES -50%"');
|
||||
Reference in New Issue
Block a user