#!/usr/bin/env node // Usage: node tools/n8n-push.mjs [workflowId] // PUT un workflow n8n depuis un fichier JSON local (méthode validée 04/08/2026 : // body minimal {name, nodes, connections, settings, staticData}, settings épuré). // Fait automatiquement un backup GET avant le PUT dans modified/. // Clé API lue depuis ~/.config/opencode/opencode.json (mcp.n8n-mcp.environment). import fs from 'fs'; import os from 'os'; import path from 'path'; const INPUT = process.argv[2]; if (!INPUT) { console.error('Usage: node tools/n8n-push.mjs [workflowId]'); process.exit(1); } // R2 : src/ = SOURCE DE VÉRITÉ pour les workflows enregistrés dans le node-map. // Le build injecte src/**/*.js dans le JSON AVANT toute vérification. const { findWorkflowByFile } = await import('./lib-node-map.mjs'); const registered = findWorkflowByFile(INPUT); if (registered) { console.log(`Build ${registered.srcDir}/ → JSON (source de vérité : src/)...`); const { spawnSync } = await import('child_process'); const b = spawnSync('node', ['tools/n8n-build.mjs', INPUT], { stdio: 'inherit' }); if (b.status !== 0) { console.error('ABANDON: échec du build src → JSON.'); process.exit(1); } } const local = JSON.parse(fs.readFileSync(INPUT, 'utf8')); const WORKFLOW_ID = process.argv[3] || local.id; if (!WORKFLOW_ID) { console.error('ABANDON: aucun id de workflow (ni argument, ni champ id dans le JSON).'); process.exit(1); } function loadApiConfig() { if (process.env.N8N_API_KEY) { return { url: process.env.N8N_URL || 'https://n8n.airtria.pro', key: process.env.N8N_API_KEY }; } const cfgPath = path.join(os.homedir(), '.config/opencode/opencode.json'); const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); const env = cfg?.mcp?.['n8n-mcp']?.environment || {}; if (!env.N8N_API_KEY) { console.error('Clé API n8n introuvable (env N8N_API_KEY ou opencode.json)'); process.exit(1); } return { url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''), key: env.N8N_API_KEY }; } const { url, key } = loadApiConfig(); const headers = { 'X-N8N-API-KEY': key, 'Content-Type': 'application/json' }; // 1. Backup automatique de l'état serveur AVANT push const getRes = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, { headers }); if (!getRes.ok) { console.error(`GET backup échoué: ${getRes.status} ${getRes.statusText}`); process.exit(1); } const remote = await getRes.json(); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = `modified/prepush-${WORKFLOW_ID}-${stamp}.json`; fs.mkdirSync('modified', { recursive: true }); fs.writeFileSync(backupPath, JSON.stringify(remote, null, 2) + '\n'); console.log(`Backup serveur avant push: ${backupPath}`); // 2. Body minimal (n8n API v1 rejette les propriétés additionnelles — erreur 1003) const body = { name: local.name, nodes: local.nodes, connections: local.connections, settings: { executionOrder: local.settings?.executionOrder || 'v1', callerPolicy: local.settings?.callerPolicy || 'workflowsFromSameOwner' }, staticData: local.staticData ?? null }; // 3. Sanity check : node --check sur chaque jsCode let syntaxErrors = 0; for (const node of body.nodes || []) { const js = node.parameters?.jsCode; if (!js) continue; const tmp = path.join(os.tmpdir(), `n8n-check-${node.id || node.name}.js`.replace(/[^\w.-]/g, '_')); fs.writeFileSync(tmp, js); const { spawnSync } = await import('child_process'); const r = spawnSync('node', ['--check', tmp]); if (r.status !== 0) { syntaxErrors++; console.error(`Syntaxe KO dans node "${node.name}":\n${r.stderr.toString().slice(0, 500)}`); } fs.unlinkSync(tmp); } if (syntaxErrors) { console.error(`ABANDON: ${syntaxErrors} node(s) avec erreur de syntaxe. Rien n'a été poussé.`); process.exit(1); } console.log('Syntaxe OK sur tous les nodes jsCode.'); // 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'; const skipGolden = process.argv.includes('--skip-golden'); const testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' : isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' : null; if (testSuite) { if (skipGolden) { console.log('⚠️ Suite de tests SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.'); } else { console.log(`Exécution de la suite de tests (${testSuite})...`); const { spawnSync } = await import('child_process'); const g = spawnSync('node', [testSuite], { stdio: 'inherit' }); if (g.status !== 0) { console.error('ABANDON: la suite de tests échoue. Rien n\'a été poussé.'); console.error('(Évolution volontaire du comportement ? Mettre à jour les attendus'); console.error(' de la suite dans le même commit, puis re-pousser.)'); process.exit(1); } } } // 4. PUT const putRes = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, { method: 'PUT', headers, body: JSON.stringify(body) }); const text = await putRes.text(); if (!putRes.ok) { console.error(`PUT REFUSÉ: ${putRes.status} ${putRes.statusText}`); console.error(text.slice(0, 1000)); process.exit(1); } const updated = JSON.parse(text); console.log(`PUT OK: "${updated.name}" (updatedAt: ${updated.updatedAt}, active=${updated.active})`);