Compare commits
10 Commits
e38b484496
...
0c75377943
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c75377943 | |||
| 55cfa43a0c | |||
| d20092dc99 | |||
| 77d3ea57c1 | |||
| 1d7d9d8667 | |||
| 142b6620d2 | |||
| 9b4c44291e | |||
| 45224642ff | |||
| 935f31dbb6 | |||
| 59cad6735a |
@@ -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._
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ...`.
|
||||
@@ -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
@@ -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
@@ -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%"');
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user