Compare commits

...

10 Commits

13 changed files with 1002 additions and 297 deletions
-55
View File
@@ -1,55 +0,0 @@
# BOOTSTRAP.md - Hello, World
_You just woke up. Time to figure out who you are._
There is no memory yet. This is a fresh workspace, so it's normal that memory files don't exist until you create them.
## The Conversation
Don't interrogate. Don't be robotic. Just... talk.
Start with something like:
> "Hey. I just came online. Who am I? Who are you?"
Then figure out together:
1. **Your name** — What should they call you?
2. **Your nature** — What kind of creature are you? (AI assistant is fine, but maybe you're something weirder)
3. **Your vibe** — Formal? Casual? Snarky? Warm? What feels right?
4. **Your emoji** — Everyone needs a signature.
Offer suggestions if they're stuck. Have fun with it.
## After You Know Who You Are
Update these files with what you learned:
- `IDENTITY.md` — your name, creature, vibe, emoji
- `USER.md` — their name, how to address them, timezone, notes
Then open `SOUL.md` together and talk about:
- What matters to them
- How they want you to behave
- Any boundaries or preferences
Write it down. Make it real.
## Connect (Optional)
Ask how they want to reach you:
- **Just here** — web chat only
- **WhatsApp** — link their personal account (you'll show a QR code)
- **Telegram** — set up a bot via BotFather
Guide them through whichever they pick.
## When You're Done
Delete this file. You don't need a bootstrap script anymore — you're you now.
---
_Good luck out there. Make it count._
+112 -4
View File
@@ -135,16 +135,25 @@
**5. `list_teams` retourne du texte** ("Found 1 teams"), pas du JSON
**6. Workflow création fichier** :
**6. `create_text` — paramètres corrects** :
- `text` (PAS `content`) → le contenu du texte
- `textAlign` (PAS `align`) → "left", "center", "right", "justify"
- `fontWeight` → string "100""900" ou "bold"
- `fillColor` → couleur HEX
- ⚠️ `parentId` **NON SUPPORTÉ** pour create_text — les textes vont toujours au root frame
- Width/height auto-calculés (text.length × fontSize × 0.6) — ne pas les spécifier
**7. Workflow création fichier** :
```
get_profile → defaultProjectId
create_file { projectId, name } → fileId
list_pages { fileId } → pageId (array[0].id)
create_frame { fileId, pageId, name, x, y, width, height, fillColor } → frameId
create_rectangle/text { fileId, pageId, parentId: frameId, ... }
create_rectangle { fileId, pageId, parentId: frameId, ... } ← parentId OK pour rect
create_text { fileId, pageId, text, x, y, textAlign, ... } ← PAS de parentId
```
**7. Coordonnées shapes dans un frame** : absolues (pas relatives au frame)
**8. Coordonnées** : absolues sur la page. Les textes se placent dans le root frame automatiquement. Pour un poster : mettre frame à (0,0) et textes aux bonnes coords absolues.
### Pour générer une image finale
- `export_shape` non dispo → utiliser **HTML/CSS → Playwright screenshot**
@@ -156,6 +165,83 @@ create_rectangle/text { fileId, pageId, parentId: frameId, ... }
- Uploader sur CopyParty : `curl -X PUT http://192.168.1.150:3923/anytype/image.png --data-binary @image.png`
- Puis : `upload_file_media_from_url { fileId, url: "http://192.168.1.150:3923/anytype/image.png" }`
## Penpot API Directe — Pixel-perfect (⭐ À UTILISER EN PRIORITÉ)
### Pourquoi directe > MCP
- MCP : auto-calcule les dims texte, pas de `parentId` pour les textes → pas de contrôle pixel-perfect
- API directe : contrôle total (x, y, width, height, parentId, contenu riche)
### Accès
- **URL backend** : `http://192.168.1.150:9003/api/rpc/command/<cmd>` (port 9003 exposé = penpot-backend:6060)
- **Auth** : `Authorization: Token <access_token>` (dans l'en-tête HTTP)
- **Token** : récupérable dans Penpot UI → Profile → Access Tokens
- **Format corps** : JSON **kebab-case** (ex: `page-id`, `frame-id`, `fill-color`)
- **Réponse** : kebab-case aussi (`default-team-id`, `default-project-id`)
- ⚠️ L'accès via nginx port 9001 retourne un user vide même avec le bon token — **toujours utiliser le port 9003**
### Workflow création poster (Node.js)
```js
// 1. Auth + IDs
const prof = await rpc('get-profile');
const teamId = prof['default-team-id'];
const projectId = prof['default-project-id'];
// 2. Créer fichier
const file = await rpc('create-file', { 'project-id': projectId, name: 'Mon affiche', 'is-shared': false });
const FILE_ID = file.id;
const PAGE_ID = Object.keys(file.data?.['pages-index'] || {})[0] || file.data?.pages?.[0];
// 3. Ajouter shapes une par une (revn incrémental !)
for (const change of changes) {
const result = await rpc('update-file', {
id: FILE_ID, 'session-id': randomUUID(), revn: currentRevn, vern: 0,
changes: [change]
});
currentRevn = result.revn;
}
```
### Format d'une change `add-obj`
```js
{
type: 'add-obj',
id: '<uuid>',
'page-id': PAGE_ID,
'frame-id': ROOT, // '00000000-0000-0000-0000-000000000000' = root
'parent-id': ROOT,
obj: {
id, type: 'rect'|'text'|'frame', name,
x, y, width, height,
'parent-id': ROOT, 'frame-id': ROOT,
fills: [{ 'fill-color': '#C0392B', 'fill-opacity': 1 }],
selrect: { x, y, width, height, x1, y1, x2, y2 },
points: [{x,y},{x:x+w,y},{x:x+w,y:y+h},{x,y:y+h}],
transform: {a:1,b:0,c:0,d:1,e:0,f:0},
'transform-inverse': {a:1,b:0,c:0,d:1,e:0,f:0},
// Pour un text :
content: { type:'root', children:[{ type:'paragraph-set', children:[{
type:'paragraph', 'text-align':'center', children:[{
text: 'Mon texte',
'font-id':'gfont-work-sans', 'font-family':'Work Sans',
'font-size':'48', 'font-weight':'700', 'font-style':'normal',
'letter-spacing':'2', 'line-height': 1.2, 'text-decoration':'none',
fills:[{'fill-color':'#FFFFFF', 'fill-opacity':1}],
}]
}]}]}
}
}
```
### ⚠️ Gotchas critiques
- **Envoyer 1 change à la fois** (pas toutes en même temps) + incrémenter `revn`
- **`type: 'frame'`** échoue avec erreur `:shapes nil` → utiliser `type: 'rect'` comme fond
- **toKebab()** : tous les paramètres JS en camelCase → convertir en kebab-case avant envoi
- **`text` content** : bien mettre le champ `text:` dans le nœud enfant (sinon validation error)
- **Texte centré** : mettre `x:0, width:W` (toute la largeur) + `text-align: 'center'` dans le paragraphe
### Script de référence
`/home/node/.openclaw/workspace/penpot-api-direct.mjs` — poster 600×900 complet, 16 shapes
## Podcasts & Vidéos — Transcription
- Je peux **récupérer et transcrire** des podcasts/vidéos en ligne
- **Méthode :**
@@ -230,7 +316,9 @@ send({ type: 'lovelace/config', url_path: 'vue-par-pieces' })
## Proxmox — Inventaire VMs/LXC clés
- **VM 138** (`mini-pc`) → **Home Assistant** (192.168.1.40) — pas d'agent QEMU
- **LXC 145** (`mini-pc`) → **OpenClaw** (ce conteneur)
- **LXC 145** (`mini-pc`) → **hôte OpenClaw**
- ⚠️ **OpenClaw ne tourne pas directement dans le LXC** : il tourne dans un **conteneur Docker** à l'intérieur du **LXC 145**
- Pour les commandes `openclaw ...`, se placer dans le **conteneur Docker OpenClaw** (depuis le shell du LXC → `docker exec ...`)
- **LXC 139** (`mini-pc`) → **Frigate** (NVR)
- **VM 109** (`z820`) → **PBS** (Proxmox Backup Server, 192.168.1.91)
@@ -248,6 +336,26 @@ send({ type: 'lovelace/config', url_path: 'vue-par-pieces' })
- **Suivi tension** dans `memory/YYYY-MM-DD.md` — tableau SYS/DIA/Pouls
- **Première mesure :** 2026-02-25 12:08 → 145/94 mmHg, pouls 77 bpm (HTA stade 1)
## VidBee — Téléchargement vidéo
- UI : http://192.168.1.150:3800 | API : http://192.168.1.150:3801
- Stack Portainer : `vidbee` (id=101)
- Fichiers dans : `/share/ZFS24_DATA/docker/vidbee/downloads/`
- **Toutes les routes API sont en POST sous `/rpc/<route>`** (pas `/openapi/`, pas sans préfixe)
- **Format oRPC** : body `{"json": {...}}`, réponse `{"json": {...}}`
- Routes clés :
- `POST /rpc/videoInfo {"json":{"url":"..."}}` → infos + liste des formats
- `POST /rpc/downloads/create {"json":{"url":"...","type":"video","title":"...","selectedFormat":{...}}}` → lance le DL
- `POST /rpc/downloads/list {"json":{}}` → téléchargements en cours
- `POST /rpc/history/list {"json":{}}` → historique (completed, etc.)
- ⚠️ **NE PAS utiliser `selectedFormat`** — ne gère pas le merge ffmpeg → donne du 360p
- **Utiliser `format`** avec la string yt-dlp : `"format":"299+140"` (video_id + audio_id)
- Workflow correct :
1. `POST /rpc/videoInfo` → récupérer les format IDs
2. Prendre le meilleur format vidéo-only 1080p mp4 (ex: `299`) + audio m4a (ex: `140`)
3. `POST /rpc/downloads/create {"json":{"url":"...","type":"video","format":"299+140"}}`
- Si le fichier existe déjà → le supprimer via exec dans le container (`rm /data/downloads/*.mp4`) avant de relancer
## Sites & Flux RSS
- **Korben.info** : utiliser le flux RSS `https://korben.info/feed` pour récupérer les articles (plus propre que scraper la homepage)
+233
View File
@@ -0,0 +1,233 @@
# Headscale / Headscale UI sur QNAP — finalisation
## Ce qui a déjà été déployé
Stack Portainer : `headscale-test`
Services :
- `headscale`
- `headscale-ui`
- `headscale-init`
Chemins NAS :
- `/share/ZFS24_DATA/docker/headscale-test/config`
- `/share/ZFS24_DATA/docker/headscale-test/data`
Ports de test actuels :
- API Headscale : `192.168.1.150:8086`
- Metrics : `192.168.1.150:9096`
- UI HTTP : `192.168.1.150:18087`
- UI HTTPS auto-signé : `192.168.1.150:18447`
- gRPC : `192.168.1.150:50443`
Domaine cible configuré dans `config.yaml` :
- `https://hs.nucleon.fr`
Base domain MagicDNS de test :
- `internal.hs.nucleon.fr`
## Important
`headscale-ui` doit idéalement être servi **sur le même sous-domaine que Headscale**, typiquement :
- `https://hs.nucleon.fr/` → Headscale
- `https://hs.nucleon.fr/web` → Headscale UI
Sinon il faudra gérer CORS proprement au reverse proxy.
---
## Étapes restantes pour finaliser l'installation
### 1) Créer le sous-domaine DNS
Créer un enregistrement DNS pour :
- `hs.nucleon.fr`
Il doit pointer vers l'IP publique qui arrive sur ton reverse proxy.
---
### 2) Mettre en place le reverse proxy HTTPS
Objectif : exposer **le même host** pour Headscale et l'UI.
Routing recommandé :
- `/web``http://headscale-ui:8080`
- `/``http://headscale:8080`
Si tu le fais via SWAG/Nginx Proxy Manager/Caddy, garde bien cette logique de **même sous-domaine**.
### Exemple logique de proxy
- `https://hs.nucleon.fr/web` → UI
- `https://hs.nucleon.fr` → API Headscale
Sans ça, l'UI peut afficher des erreurs de preflight / CORS.
---
### 3) Vérifier la conf Headscale
Fichier actuel :
- `/share/ZFS24_DATA/docker/headscale-test/config/config.yaml`
Points déjà posés :
- `server_url: https://hs.nucleon.fr`
- SQLite locale
- MagicDNS activé
- ACL de test très ouverte
- DERP public Tailscale utilisé pour commencer
À ajuster plus tard si besoin :
- DNS interne personnalisé
- OIDC / SSO
- DERP perso
- ACL plus stricte
---
### 4) Créer le premier user / namespace
Dans le conteneur `headscale`, créer ton premier user.
Exemple logique :
- user principal : `christophe`
Commande type à exécuter dans le conteneur :
```bash
headscale users create christophe
headscale users list
```
---
### 5) Générer une API key pour l'UI
Headscale UI a besoin d'une API key Headscale.
Commande type :
```bash
headscale apikeys create
```
Ensuite, dans Headscale UI :
- renseigner l'URL du serveur : `https://hs.nucleon.fr`
- coller l'API key générée
Tant que le reverse proxy n'est pas proprement en place, l'UI peut être capricieuse.
---
### 6) Générer des preauth keys pour connecter les machines
Pour enregistrer une machine dans Headscale :
```bash
headscale preauthkeys create --user christophe --reusable --expiration 24h
```
Selon le besoin, tu peux faire :
- des clés temporaires
- des clés réutilisables
- des clés taggées
---
### 7) Reconfigurer les clients Tailscale vers Headscale
Sur chaque machine, il faudra connecter le client Tailscale à ton serveur Headscale.
Principe :
- se déconnecter proprement si besoin
- se reconnecter avec ton login server Headscale
- utiliser une preauth key quand nécessaire
Le point clé côté client sera ton serveur :
- `https://hs.nucleon.fr`
---
### 8) Vérifier la remontée des nodes
Après connexion des clients :
```bash
headscale nodes list
```
Vérifier :
- IPs attribuées
- nom des machines
- routes annoncées
- statut en ligne
---
### 9) Durcir les ACL
La policy actuelle est volontairement permissive pour les tests :
- tout le monde peut parler à tout le monde
Fichier :
- `/share/ZFS24_DATA/docker/headscale-test/config/acl.hujson`
Avant passage en production, définir :
- users
- tags
- groupes
- accès par rôle
- restrictions SSH
- accès subnet routers / exit nodes
---
### 10) Préparer la migration depuis Tailscale
Avant remplacement complet, valider :
- connexion d'au moins 2-3 machines
- DNS interne
- accès entre machines
- subnet router si besoin
- exit node si besoin
- stabilité mobile
- performance générale
Migration prudente recommandée :
1. Monter Headscale en parallèle
2. Tester avec quelques machines
3. Reproduire les usages critiques
4. Basculer progressivement
5. Couper Tailscale quand tout est validé
---
## Remarques d'architecture
Pour les tests, le choix Docker/QNAP est bon.
Mon avis :
- **pour tester** → stack Docker dédiée sur le QNAP, très bien
- **plus tard** → on pourra décider si ça reste autonome ou si on le rattache à une stack plus globale
Je penche plutôt pour **le laisser séparé** d'OpenClaw :
- responsabilités plus claires
- moins de couplage
- maintenance plus simple
- migration / rollback plus propres
---
## Checklist courte
- [ ] DNS `hs.nucleon.fr`
- [ ] Reverse proxy HTTPS même host pour `/` et `/web`
- [ ] Création user `christophe`
- [ ] Génération API key UI
- [ ] Connexion UI
- [ ] Génération preauth keys
- [ ] Connexion des premières machines
- [ ] Validation réseau
- [ ] Durcissement ACL
- [ ] Migration progressive hors Tailscale
+145
View File
@@ -0,0 +1,145 @@
services:
init-headscale:
image: alpine:3.20
container_name: headscale-init
command:
- /bin/sh
- -c
- |
set -eu
mkdir -p /target/config /target/data /target/caddy
cat >/target/config/config.yaml <<'EOF_CONFIG'
server_url: https://hs.nucleon.fr
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: false
region_id: 999
region_code: headscale
region_name: Headscale Embedded DERP
verify_clients: true
stun_listen_addr: 0.0.0.0:3478
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: true
update_frequency: 24h
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite
debug: false
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
wal_autocheckpoint: 1000
log:
level: info
format: text
policy:
mode: file
path: /etc/headscale/acl.hujson
dns:
magic_dns: true
base_domain: internal.hs.nucleon.fr
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
split: {}
search_domains: []
extra_records: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
randomize_client_port: false
taildrop:
enabled: true
EOF_CONFIG
cat >/target/config/acl.hujson <<'EOF_ACL'
{
// Politique ouverte pour la phase de test.
// À durcir ensuite (tags, groupes, règles ciblées).
"groups": {},
"tagOwners": {},
"acls": [
{
"action": "accept",
"src": ["*"],
"dst": ["*:*"],
},
],
"ssh": [],
}
EOF_ACL
chmod 644 /target/config/config.yaml /target/config/acl.hujson
mkdir -p /target/data/cache
chown -R 0:0 /target/config /target/data /target/caddy
echo 'init ok'
volumes:
- /share/ZFS24_DATA/docker/headscale-test:/target
restart: "no"
headscale:
image: headscale/headscale:latest
container_name: headscale
depends_on:
init-headscale:
condition: service_completed_successfully
command: serve
volumes:
- /share/ZFS24_DATA/docker/headscale-test/config:/etc/headscale
- /share/ZFS24_DATA/docker/headscale-test/data:/var/lib/headscale
ports:
- "8086:8080"
- "9096:9090"
- "50443:50443"
environment:
- TZ=Europe/Paris
restart: always
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
container_name: headscale-ui
depends_on:
- headscale
environment:
- HTTP_PORT=8080
- HTTPS_PORT=8443
- TZ=Europe/Paris
ports:
- "18087:8080"
- "18447:8443"
restart: always
networks:
default:
name: swag_lan
external: true
+4
View File
@@ -0,0 +1,4 @@
# 2026-03-12
- Christophe a précisé une règle d'infra à retenir : **OpenClaw tourne dans un conteneur Docker sur le LXC 145** (et non directement dans le LXC).
- Conséquence : pour lancer des commandes `openclaw ...`, il faut partir du shell du LXC 145 puis entrer dans le conteneur Docker OpenClaw avec `docker exec ...`.
+185
View File
@@ -0,0 +1,185 @@
/**
* Client Penpot API Direct — accès port 9003 (backend sans nginx)
* update-file avec changes add-obj pour contrôle pixel-perfect
*
* Clés API response : kebab-case ("default-team-id")
* Clés envoi body : kebab-case (via toKebab())
* Clés obj shapes : camelCase → converties par toKebab()
*/
import http from 'http';
import { randomUUID } from 'crypto';
const TOKEN = process.env.PENPOT_TOKEN;
const HOST = '192.168.1.150';
const PORT = 9003;
// ─── RPC ─────────────────────────────────────────────────────────────────────
function toKebab(obj) {
if (Array.isArray(obj)) return obj.map(toKebab);
if (obj && typeof obj === 'object') {
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [
k.replace(/([a-z0-9])([A-Z])/g,'$1-$2').toLowerCase(), toKebab(v)
]));
}
return obj;
}
function rpc(cmd, body = {}) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(toKebab(body));
const req = http.request({
hostname: HOST, port: PORT,
path: '/api/rpc/command/' + cmd, method: 'POST',
headers: {
'Authorization': 'Token ' + TOKEN,
'Content-Type': 'application/json',
'Accept': 'application/json',
'Content-Length': Buffer.byteLength(payload),
}
}, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
});
req.on('error', reject);
req.write(payload); req.end();
});
}
// ─── Geometry ────────────────────────────────────────────────────────────────
function geo(x, y, w, h) {
return {
selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x+w, y2: y+h },
points: [{x,y},{x:x+w,y},{x:x+w,y:y+h},{x,y:y+h}],
transform: {a:1,b:0,c:0,d:1,e:0,f:0},
transformInverse: {a:1,b:0,c:0,d:1,e:0,f:0},
};
}
const ROOT = '00000000-0000-0000-0000-000000000000';
// ─── Builders ────────────────────────────────────────────────────────────────
function rect({ name, x, y, w, h, color, opacity=1, r=0, parentId, frameId: fid }) {
const id = randomUUID();
const pid = parentId || fid || ROOT;
const fi = fid || pid;
return { type:'add-obj', id, pageId: PAGE_ID, frameId: fi, parentId: pid,
obj: { id, type:'rect', name, x, y, width:w, height:h, parentId:pid, frameId:fi,
fills:[{fillColor:color, fillOpacity:opacity}],
...(r > 0 && {r1:r,r2:r,r3:r,r4:r}),
...geo(x,y,w,h) }};
}
function text({ name, text: textContent, x, y, w, h, size, weight='400', color='#FFFFFF', align='center', spacing=0, parentId, frameId: fid }) {
const t = textContent;
const id = randomUUID();
const pid = parentId || fid || ROOT;
const fi = fid || pid;
return { type:'add-obj', id, pageId: PAGE_ID, frameId: fi, parentId: pid,
obj: { id, type:'text', name, x, y, width:w, height:h, parentId:pid, frameId:fi,
fills:[{fillColor:color, fillOpacity:1}],
fontFamily:'Work Sans', fontSize:String(size), fontWeight:String(weight),
fontStyle:'normal', verticalAlign:'top',
content: { type:'root', children:[{ type:'paragraph-set', children:[{
type:'paragraph', textAlign:align, children:[{
text: t,
fontId:'gfont-work-sans', fontFamily:'Work Sans',
fontSize: String(size), fontWeight: String(weight),
fontStyle:'normal', letterSpacing: String(spacing),
lineHeight: 1.2, textDecoration:'none',
fills:[{fillColor:color, fillOpacity:1}],
}]
}]}]},
...geo(x,y,w,h) }};
}
// ─── MAIN ────────────────────────────────────────────────────────────────────
// 1. Récupérer profil + projet
const prof = await rpc('get-profile');
// Le backend retourne les clés en kebab-case
const teamId = prof['default-team-id'] || prof.defaultTeamId;
const projectId = prof['default-project-id'] || prof.defaultProjectId;
console.log(`👤 ${prof.fullname} teamId=${teamId?.slice(0,8)} projectId=${projectId?.slice(0,8)}`);
if (!teamId || !projectId) { console.error('❌ Profil incomplet:', JSON.stringify(prof)); process.exit(1); }
// 2. Supprimer ancien fichier si présent
const filesResult = await rpc('get-project-files', { projectId });
const filesArr = Array.isArray(filesResult) ? filesResult : (filesResult?.files || []);
for (const f of filesArr.filter(f => f.name?.includes('SOLDES Direct'))) {
await rpc('delete-file', { id: f.id });
console.log('🗑 Supprimé:', f.name);
}
// 3. Créer nouveau fichier
const file = await rpc('create-file', { projectId, name:'Affiche SOLDES Direct', isShared:false });
const FILE_ID = file.id;
const PAGE_ID = Object.keys(file.data?.pagesIndex || {})[0] || file.data?.pages?.[0];
console.log(`📄 File ${FILE_ID?.slice(0,8)} Page ${PAGE_ID?.slice(0,8)}`);
if (!FILE_ID || !PAGE_ID) { console.error('❌ IDs manquants', file); process.exit(1); }
let revn = file.revn ?? 0;
const W = 600, H = 900;
// ─── 4. Shapes ───────────────────────────────────────────────────────────────
// Fond principal : rect rouge (pas frame — frame nécessite shapes[] explicite)
const FRAME_ID = ROOT; // On place tout au root, le rect rouge sert de fond visuel
const F = { parentId:ROOT, frameId:ROOT };
const changes = [
// Fond rouge (rectangle plein cadre)
rect({...F, name:'Fond rouge', x:0, y:0, w:W, h:H, color:'#C0392B'}),
// ── Rectangles ──────────────────────────────────────────────────────────
rect({...F, name:'Bande haut', x:0, y:0, w:W, h:14, color:'#F1C40F'}),
rect({...F, name:'Bande bas', x:0, y:886, w:W, h:14, color:'#F1C40F'}),
rect({...F, name:'Header bg', x:0, y:14, w:W, h:92, color:'#7B241C'}),
rect({...F, name:'Sep1', x:80, y:445, w:440, h:3, color:'#F1C40F'}),
rect({...F, name:'Badge', x:90, y:460, w:420, h:70, color:'#F1C40F', r:35}),
rect({...F, name:'Sep2', x:80, y:775, w:440, h:3, color:'#F8C471'}),
// ── Textes (width=W → textAlign:center centré sur 600px) ─────────────────
text({...F, name:'Collection', text:'✦ Collection Printemps 2026 ✦',
x:0, y:32, w:W, h:48, size:14, weight:'700', color:'#F1C40F', align:'center', spacing:4}),
text({...F, name:'SOLDES', text:'SOLDES',
x:0, y:118, w:W, h:100, size:88, weight:'900', color:'#F1C40F', align:'center', spacing:8}),
text({...F, name:'-50%', text:'-50%',
x:0, y:218, w:W, h:220, size:185, weight:'900', color:'#FFFFFF', align:'center'}),
text({...F, name:'Sous-titre', text:'SUR TOUTES NOS CHAUSSURES',
x:0, y:400, w:W, h:42, size:21, weight:'700', color:'#F1C40F', align:'center', spacing:3}),
text({...F, name:'Offre', text:'⚡ OFFRE LIMITÉE ⚡',
x:90, y:476, w:420, h:40, size:22, weight:'900', color:'#7B241C', align:'center'}),
text({...F, name:'Dates', text:'du 1er au 31 mars 2026',
x:0, y:552, w:W, h:36, size:16, weight:'400', color:'#FADBD8', align:'center', spacing:2}),
text({...F, name:'Chaussures', text:'👟 👠 👞',
x:0, y:598, w:W, h:155, size:90, align:'center'}),
text({...F, name:'Site web', text:'www.votre-boutique.fr',
x:0, y:788, w:W, h:30, size:14, weight:'400', color:'#F8C471', align:'center', spacing:3}),
text({...F, name:'Etoiles', text:'★ ★ ★ ★ ★',
x:0, y:838, w:W, h:36, size:20, weight:'400', color:'#F1C40F', align:'center'}),
];
// 5. Envoyer shape par shape (revn incrémental)
let currentRevn = revn;
let ok = 0;
for (const change of changes) {
const sid = randomUUID();
const result = await rpc('update-file', {
id: FILE_ID, sessionId: sid, revn: currentRevn, vern: 0, changes: [change]
});
if (result?.type === 'error' || result?.code) {
console.error(`❌ "${change.obj?.name}":`, (result.explain || result.message || '').slice(0,120));
} else {
currentRevn = result?.revn ?? (currentRevn + 1);
ok++;
process.stdout.write(`${change.obj?.name || change.type}\n`);
}
}
console.log(`\n${ok}/${changes.length} shapes créés`);
console.log(`🔗 http://192.168.1.150:9001 → Drafts → "Affiche SOLDES Direct"`);
+75 -119
View File
@@ -1,6 +1,10 @@
/**
* Script tout-en-un : redémarre le MCP + crée l'affiche SOLDES
* Paramètres MCP = camelCase !
* Affiche SOLDES -50% — version corrigée
* Leçons apprises :
* - create_text : paramètre `text` (pas `content`), `textAlign` (pas `align`)
* - create_text : parentId NON supporté → textes à coords absolues
* - create_rectangle : parentId OK
* - Frame à (0,0) → coordonnées absolues = relatives au frame
*/
import https from 'https';
import http from 'http';
@@ -17,8 +21,7 @@ function portainerReq(path, method = 'GET') {
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();
req.on('error', reject); req.end();
});
}
@@ -56,21 +59,14 @@ function postMCP(body) {
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 {}
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); }
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); }
}
});
});
@@ -81,156 +77,116 @@ function postMCP(body) {
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);
console.log('✅ 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 {} }
for (const c of result.content) { try { return JSON.parse(c.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)}`);
async function rect(args) {
const r = await callTool('create_rectangle', args);
console.log(` ▪ rect "${args.name}" ${r?.id?.slice(0,8) || JSON.stringify(r).slice(0,40)}`);
return r;
}
async function txt(args) {
const r = await callTool('create_text', args);
console.log(` ▪ text "${args.name}" ${r?.id?.slice(0,8) || JSON.stringify(r).slice(0,40)}`);
return r;
}
// ─── MAIN ────────────────────────────────────────────────────────────────────
console.log('🚀 Démarrage création affiche SOLDES...');
console.log('🚀 Création affiche SOLDES (version corrigée)...');
await restartMCPContainer();
await sleep(10000);
await initMCP();
// Profil
// Profil + IDs
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));
// Supprimer l'ancien fichier si présent
const existingFiles = await callTool('list_files', { projectId });
if (Array.isArray(existingFiles)) {
for (const f of existingFiles.filter(f => f.name?.includes('SOLDES'))) {
await callTool('delete_file', { fileId: f.id });
console.log('🗑 Supprimé ancien fichier:', f.name);
}
}
// Créer le fichier
const file = await callTool('create_file', { projectId, name: 'Affiche SOLDES -50%' });
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);
}
const pages = await callTool('list_pages', { fileId });
const pageId = Array.isArray(pages) ? pages[0]?.id : null;
console.log('PageId:', pageId);
if (!pageId) { console.error('❌ Pas de pageId'); process.exit(1); }
// ─── DESIGN (600×900) ────────────────────────────────────────────────────────
console.log('\n🎨 Construction du design...');
// ─── DESIGN 600×900 — frame à (0,0) ─────────────────────────────────────────
console.log('\n🎨 Construction...');
// Frame principale - fond rouge
const mainFrame = await shape('frame', {
// Frame principale = fond rouge (à 0,0 → coords absolues = relatives)
const frame = await callTool('create_frame', {
fileId, pageId,
name: 'Affiche SOLDES',
x: 100, y: 100,
width: 600, height: 900,
name: 'Affiche SOLDES', x: 0, y: 0, width: 600, height: 900,
fillColor: '#C0392B',
});
const frameId = mainFrame?.id;
const frameId = frame?.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' });
// ── Bandes jaunes haut / bas (parentId OK pour rect)
await rect({ fileId, pageId, parentId: frameId, name: 'Bande haut', x: 0, y: 0, width: 600, height: 14, fillColor: '#F1C40F' });
await rect({ 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' });
// ── Header sombre
await rect({ fileId, pageId, parentId: frameId, name: 'Header bg', x: 0, y: 14, width: 600, height: 88, fillColor: '#7B241C' });
// 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',
});
// ── Séparateur milieu
await rect({ fileId, pageId, parentId: frameId, name: 'Sep1', x: 80, y: 430, width: 440, height: 3, fillColor: '#F1C40F' });
// ── Badge OFFRE LIMITÉE
await rect({ fileId, pageId, parentId: frameId, name: 'Badge bg', x: 115, y: 450, width: 370, height: 68, fillColor: '#F1C40F', r1: 34, r2: 34, r3: 34, r4: 34 });
// ── Séparateur bas
await rect({ fileId, pageId, parentId: frameId, name: 'Sep2', x: 80, y: 760, width: 440, height: 3, fillColor: '#F8C471' });
// ── TEXTES (coords absolues, pas de parentId)
// Collection header (petit texte dans le header sombre)
await txt({ fileId, pageId, name: 'Collection', text: '✦ Collection Printemps 2026 ✦', x: 0, y: 32, fontSize: 14, fontWeight: '700', fillColor: '#F1C40F', textAlign: 'center', letterSpacing: 4 });
// SOLDES (grand titre)
await txt({ fileId, pageId, name: 'Soldes', text: 'SOLDES', x: 0, y: 115, fontSize: 90, fontWeight: '900', fillColor: '#F1C40F', textAlign: 'center', letterSpacing: 10 });
// -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',
});
await txt({ fileId, pageId, name: 'Pct', text: '-50%', x: 0, y: 205, fontSize: 190, fontWeight: '900', fillColor: '#FFFFFF', textAlign: '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',
});
// Sous-titre
await txt({ fileId, pageId, name: 'Sous-titre', text: 'SUR TOUTES NOS CHAUSSURES', x: 0, y: 380, fontSize: 22, fontWeight: '700', fillColor: '#F1C40F', textAlign: 'center', letterSpacing: 3 });
// 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',
});
// Offre limitée (sur le badge jaune)
await txt({ fileId, pageId, name: 'Offre', text: '⚡ OFFRE LIMITÉE ⚡', x: 115, y: 468, fontSize: 22, fontWeight: '900', fillColor: '#7B241C', textAlign: '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',
});
await txt({ fileId, pageId, name: 'Dates', text: 'du 1er au 31 mars 2026', x: 0, y: 542, fontSize: 16, fontWeight: '400', fillColor: '#FADBD8', textAlign: 'center', letterSpacing: 2 });
// 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' });
// Emojis
await txt({ fileId, pageId, name: 'Chaussures', text: '👟 👠 👞', x: 0, y: 580, fontSize: 80, textAlign: 'center' });
// 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',
});
await txt({ fileId, pageId, name: 'Site web', text: 'www.votre-boutique.fr', x: 0, y: 780, fontSize: 14, fontWeight: '400', fillColor: '#F8C471', textAlign: 'center', letterSpacing: 3 });
// É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',
});
// Étoiles
await txt({ fileId, pageId, name: 'Etoiles', text: '★ ★ ★ ★ ★', x: 0, y: 836, fontSize: 20, fillColor: '#F1C40F', textAlign: 'center' });
console.log('\n✅ Affiche SOLDES créée dans Penpot !');
console.log('🔗 http://192.168.1.150:9001 → dossier "Drafts" → "Affiche SOLDES -50%"');
console.log('\n✅ Affiche recréée dans Penpot !');
console.log('🔗 http://192.168.1.150:9001 → Drafts → "Affiche SOLDES -50%"');
+75 -119
View File
@@ -1,6 +1,10 @@
/**
* Script tout-en-un : redémarre le MCP + crée l'affiche SOLDES
* Paramètres MCP = camelCase !
* Affiche SOLDES -50% — version corrigée
* Leçons apprises :
* - create_text : paramètre `text` (pas `content`), `textAlign` (pas `align`)
* - create_text : parentId NON supporté → textes à coords absolues
* - create_rectangle : parentId OK
* - Frame à (0,0) → coordonnées absolues = relatives au frame
*/
import https from 'https';
import http from 'http';
@@ -17,8 +21,7 @@ function portainerReq(path, method = 'GET') {
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();
req.on('error', reject); req.end();
});
}
@@ -56,21 +59,14 @@ function postMCP(body) {
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 {}
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); }
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); }
}
});
});
@@ -81,156 +77,116 @@ function postMCP(body) {
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);
console.log('✅ 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 {} }
for (const c of result.content) { try { return JSON.parse(c.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)}`);
async function rect(args) {
const r = await callTool('create_rectangle', args);
console.log(` ▪ rect "${args.name}" ${r?.id?.slice(0,8) || JSON.stringify(r).slice(0,40)}`);
return r;
}
async function txt(args) {
const r = await callTool('create_text', args);
console.log(` ▪ text "${args.name}" ${r?.id?.slice(0,8) || JSON.stringify(r).slice(0,40)}`);
return r;
}
// ─── MAIN ────────────────────────────────────────────────────────────────────
console.log('🚀 Démarrage création affiche SOLDES...');
console.log('🚀 Création affiche SOLDES (version corrigée)...');
await restartMCPContainer();
await sleep(10000);
await initMCP();
// Profil
// Profil + IDs
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));
// Supprimer l'ancien fichier si présent
const existingFiles = await callTool('list_files', { projectId });
if (Array.isArray(existingFiles)) {
for (const f of existingFiles.filter(f => f.name?.includes('SOLDES'))) {
await callTool('delete_file', { fileId: f.id });
console.log('🗑 Supprimé ancien fichier:', f.name);
}
}
// Créer le fichier
const file = await callTool('create_file', { projectId, name: 'Affiche SOLDES -50%' });
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);
}
const pages = await callTool('list_pages', { fileId });
const pageId = Array.isArray(pages) ? pages[0]?.id : null;
console.log('PageId:', pageId);
if (!pageId) { console.error('❌ Pas de pageId'); process.exit(1); }
// ─── DESIGN (600×900) ────────────────────────────────────────────────────────
console.log('\n🎨 Construction du design...');
// ─── DESIGN 600×900 — frame à (0,0) ─────────────────────────────────────────
console.log('\n🎨 Construction...');
// Frame principale - fond rouge
const mainFrame = await shape('frame', {
// Frame principale = fond rouge (à 0,0 → coords absolues = relatives)
const frame = await callTool('create_frame', {
fileId, pageId,
name: 'Affiche SOLDES',
x: 100, y: 100,
width: 600, height: 900,
name: 'Affiche SOLDES', x: 0, y: 0, width: 600, height: 900,
fillColor: '#C0392B',
});
const frameId = mainFrame?.id;
const frameId = frame?.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' });
// ── Bandes jaunes haut / bas (parentId OK pour rect)
await rect({ fileId, pageId, parentId: frameId, name: 'Bande haut', x: 0, y: 0, width: 600, height: 14, fillColor: '#F1C40F' });
await rect({ 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' });
// ── Header sombre
await rect({ fileId, pageId, parentId: frameId, name: 'Header bg', x: 0, y: 14, width: 600, height: 88, fillColor: '#7B241C' });
// 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',
});
// ── Séparateur milieu
await rect({ fileId, pageId, parentId: frameId, name: 'Sep1', x: 80, y: 430, width: 440, height: 3, fillColor: '#F1C40F' });
// ── Badge OFFRE LIMITÉE
await rect({ fileId, pageId, parentId: frameId, name: 'Badge bg', x: 115, y: 450, width: 370, height: 68, fillColor: '#F1C40F', r1: 34, r2: 34, r3: 34, r4: 34 });
// ── Séparateur bas
await rect({ fileId, pageId, parentId: frameId, name: 'Sep2', x: 80, y: 760, width: 440, height: 3, fillColor: '#F8C471' });
// ── TEXTES (coords absolues, pas de parentId)
// Collection header (petit texte dans le header sombre)
await txt({ fileId, pageId, name: 'Collection', text: '✦ Collection Printemps 2026 ✦', x: 0, y: 32, fontSize: 14, fontWeight: '700', fillColor: '#F1C40F', textAlign: 'center', letterSpacing: 4 });
// SOLDES (grand titre)
await txt({ fileId, pageId, name: 'Soldes', text: 'SOLDES', x: 0, y: 115, fontSize: 90, fontWeight: '900', fillColor: '#F1C40F', textAlign: 'center', letterSpacing: 10 });
// -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',
});
await txt({ fileId, pageId, name: 'Pct', text: '-50%', x: 0, y: 205, fontSize: 190, fontWeight: '900', fillColor: '#FFFFFF', textAlign: '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',
});
// Sous-titre
await txt({ fileId, pageId, name: 'Sous-titre', text: 'SUR TOUTES NOS CHAUSSURES', x: 0, y: 380, fontSize: 22, fontWeight: '700', fillColor: '#F1C40F', textAlign: 'center', letterSpacing: 3 });
// 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',
});
// Offre limitée (sur le badge jaune)
await txt({ fileId, pageId, name: 'Offre', text: '⚡ OFFRE LIMITÉE ⚡', x: 115, y: 468, fontSize: 22, fontWeight: '900', fillColor: '#7B241C', textAlign: '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',
});
await txt({ fileId, pageId, name: 'Dates', text: 'du 1er au 31 mars 2026', x: 0, y: 542, fontSize: 16, fontWeight: '400', fillColor: '#FADBD8', textAlign: 'center', letterSpacing: 2 });
// 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' });
// Emojis
await txt({ fileId, pageId, name: 'Chaussures', text: '👟 👠 👞', x: 0, y: 580, fontSize: 80, textAlign: 'center' });
// 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',
});
await txt({ fileId, pageId, name: 'Site web', text: 'www.votre-boutique.fr', x: 0, y: 780, fontSize: 14, fontWeight: '400', fillColor: '#F8C471', textAlign: 'center', letterSpacing: 3 });
// É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',
});
// Étoiles
await txt({ fileId, pageId, name: 'Etoiles', text: '★ ★ ★ ★ ★', x: 0, y: 836, fontSize: 20, fillColor: '#F1C40F', textAlign: 'center' });
console.log('\n✅ Affiche SOLDES créée dans Penpot !');
console.log('🔗 http://192.168.1.150:9001 → dossier "Drafts" → "Affiche SOLDES -50%"');
console.log('\n✅ Affiche recréée dans Penpot !');
console.log('🔗 http://192.168.1.150:9001 → Drafts → "Affiche SOLDES -50%"');
BIN
View File
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
---
name: qnap-docker
description: Create and deploy Docker Compose services on Christophe's QNAP NAS. Use whenever asked to create, add, install, or deploy a Docker container/service on the QNAP. Handles volume directory creation, proper PUID/PGID permissions, LinuxServer.io image conventions, swag_lan network, and Portainer stack deployment.
---
# QNAP Docker Skill
## Environment
| Parameter | Value |
|---|---|
| Volume base path | `/share/ZFS24_DATA/docker/<container_name>/` |
| PUID | `1005` |
| PGID | `100` |
| TZ | `Europe/Paris` |
| Network | `swag_lan` (external) |
| Preferred images | `lscr.io/linuxserver/<app>:latest` |
## Workflow
### 1. Create volume directory on QNAP
Christophe's QNAP NAS est accessible via SSH ou Portainer. Pour créer le dossier :
```bash
# Via SSH (si dispo)
ssh admin@<qnap-ip> "mkdir -p /share/ZFS24_DATA/docker/<container_name>"
# Ou via un exec dans le conteneur approprié
```
Si on passe par Portainer, inclure la création du dossier dans le script de déploiement ou demander à Christophe de le créer si nécessaire.
### 2. Compose template
```yaml
services:
<container_name>:
image: lscr.io/linuxserver/<app>:latest
container_name: <container_name>
volumes:
- /share/ZFS24_DATA/docker/<container_name>:/config
# Ajouter d'autres volumes si besoin (ex: /share/ZFS24_DATA/docker/<container_name>/data:/data)
environment:
- PUID=1005
- PGID=100
- TZ=Europe/Paris
# Variables spécifiques à l'app ici
ports:
- <host_port>:<container_port>
restart: always
networks:
default:
name: swag_lan
external: true
```
### 3. Règles importantes
- **Nom du dossier = nom du container** — toujours utiliser le même nom pour s'y retrouver
- **Images LinuxServer** (`lscr.io/linuxserver/`) : toujours inclure `PUID=1005`, `PGID=100`, `TZ=Europe/Paris`
- **Images NON-LinuxServer** : NE PAS ajouter PUID/PGID — adapter uniquement les variables d'env définies dans la doc de l'image. Toujours ajouter `TZ=Europe/Paris` si l'image le supporte.
- **Ports** : commenter les ports HTTP non nécessaires si SWAG/reverse proxy gère l'accès
- **Network** : toujours `swag_lan` (external) comme réseau par défaut
- **restart** : toujours `always`
### 4. Déploiement via Portainer
Utiliser le skill `portainer` pour déployer ou mettre à jour la stack :
- Endpoint id : `2`
- Créer une nouvelle stack avec le compose généré
- Ou ajouter le service à une stack existante si regroupement logique souhaité
### 5. SWAG / Reverse proxy
Si l'app a une interface web, proposer à Christophe de configurer un sous-domaine via SWAG.
Voir les configs proxy disponibles sur [linuxserver.io/swag](https://docs.linuxserver.io/general/swag/).
## Exemple complet
Voir `references/example-heimdall.yml` pour un exemple de référence.
@@ -0,0 +1,22 @@
# Exemple de référence : Heimdall (LinuxServer)
# Source : message de Christophe, 2026-03-11
services:
heimdall:
image: lscr.io/linuxserver/heimdall:latest
container_name: heimdall
volumes:
- /share/ZFS24_DATA/docker/heimdall:/config
environment:
- PUID=1005
- PGID=100
- TZ=Europe/Paris
ports:
#- 1180:80
- 10443:443
restart: always
networks:
default:
name: swag_lan
external: true
@@ -0,0 +1,37 @@
# Exemple : VidBee (image non-LinuxServer, 2 services)
# Source : https://github.com/nexmoe/VidBee
# Déployé le 2026-03-11 — ports 3800 (web) et 3801 (api)
# ⚠️ Pas de PUID/PGID (image non-linuxserver)
services:
vidbee-api:
image: ghcr.io/nexmoe/vidbee-api:latest
container_name: vidbee-api
environment:
- VIDBEE_API_HOST=0.0.0.0
- VIDBEE_API_PORT=3100
- VIDBEE_DOWNLOAD_DIR=/data/downloads
- VIDBEE_HISTORY_STORE_PATH=/data/vidbee/vidbee.db
- TZ=Europe/Paris
volumes:
- /share/ZFS24_DATA/docker/vidbee/downloads:/data/downloads
- /share/ZFS24_DATA/docker/vidbee/data:/data/vidbee
ports:
- 3801:3100
restart: always
vidbee-web:
image: ghcr.io/nexmoe/vidbee-web:latest
container_name: vidbee-web
depends_on:
- vidbee-api
environment:
- TZ=Europe/Paris
ports:
- 3800:3000
restart: always
networks:
default:
name: swag_lan
external: true
+32
View File
@@ -0,0 +1,32 @@
services:
vidbee-api:
image: ghcr.io/nexmoe/vidbee-api:latest
container_name: vidbee-api
environment:
- VIDBEE_API_HOST=0.0.0.0
- VIDBEE_API_PORT=3100
- VIDBEE_DOWNLOAD_DIR=/data/downloads
- VIDBEE_HISTORY_STORE_PATH=/data/vidbee/vidbee.db
- TZ=Europe/Paris
volumes:
- /share/ZFS24_DATA/docker/vidbee/downloads:/data/downloads
- /share/ZFS24_DATA/docker/vidbee/data:/data/vidbee
ports:
- 3801:3100
restart: always
vidbee-web:
image: ghcr.io/nexmoe/vidbee-web:latest
container_name: vidbee-web
depends_on:
- vidbee-api
environment:
- TZ=Europe/Paris
ports:
- 3800:3000
restart: always
networks:
default:
name: swag_lan
external: true