#!/usr/bin/env node /** * Wordly.art - Setup Wizard (Node.js) * * Usage: node scripts/setup.js * * Ce script : * 1. Pose des questions interactives * 2. Genere les secrets * 3. Hash le mot de passe admin en bcrypt * 4. Ecrit le fichier .env * 5. Lance docker compose */ const readline = require('readline'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const ENV_FILE = path.join(__dirname, '..', '.env'); // ============================================ // Helpers // ============================================ function rl() { return readline.createInterface({ input: process.stdin, output: process.stdout }); } function question(prompt, defaultVal) { return new Promise(resolve => { const r = rl(); const display = defaultVal ? `${prompt} [${defaultVal}]: ` : `${prompt}: `; r.question(display, answer => { r.close(); resolve(answer.trim() || (defaultVal || '')); }); }); } function questionPassword(prompt) { return new Promise(resolve => { const r = rl(); r.question(`${prompt}: `, answer => { r.close(); resolve(answer); }); // Hide input if (process.stdin.isTTY) { process.stdout.write('\x1b[8m'); // Hide } }).finally(() => { if (process.stdout.isTTY) { process.stdout.write('\x1b[0m'); // Show } }); } function questionPasswordSimple(prompt) { return new Promise(resolve => { const r = readline.createInterface({ input: process.stdin, output: process.stdout }); r.question(`${prompt}: `, answer => { r.close(); resolve(answer); }); }); } function generateSecret(len = 64) { return crypto.randomBytes(len).toString('base64url').slice(0, len); } function generateHex(len = 32) { return crypto.randomBytes(len).toString('hex'); } function hashBcrypt(password) { try { const safePass = password.replace(/'/g, "'\"'\"'"); const cmd = 'docker run --rm python:3.12-slim bash -c "' + "pip install 'passlib[bcrypt]' bcrypt >/dev/null 2>&1 && " + "python3 -c \\\"from passlib.context import CryptContext; " + "print(CryptContext(schemes=['bcrypt']).hash('" + safePass + "'))\\\"" + '"'; const result = execSync(cmd, { encoding: 'utf-8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] }); return result.trim(); } catch (e) { return null; } } function writeEnv(content) { fs.writeFileSync(ENV_FILE, content, 'utf-8'); // On Linux: chmod 600 try { fs.chmodSync(ENV_FILE, 0o600); } catch (e) { /* Windows */ } } function readEnv() { if (!fs.existsSync(ENV_FILE)) return {}; const content = fs.readFileSync(ENV_FILE, 'utf-8'); const env = {}; for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIdx = trimmed.indexOf('='); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx); const val = trimmed.slice(eqIdx + 1); env[key] = val; } return env; } function setEnvValues(pairs) { let content = ''; if (fs.existsSync(ENV_FILE)) { content = fs.readFileSync(ENV_FILE, 'utf-8'); } for (const [key, value] of Object.entries(pairs)) { const regex = new RegExp(`^${key}=.*$`, 'm'); if (regex.test(content)) { content = content.replace(regex, `${key}=${value}`); } else { content += `\n${key}=${value}`; } } writeEnv(content); } async function select(prompt, options) { console.log(`\n${prompt}`); options.forEach((opt, i) => console.log(` ${i + 1}) ${opt.label}`)); const answer = await question('Choix', '1'); const idx = parseInt(answer) - 1; return options[Math.max(0, Math.min(idx, options.length - 1))].value; } // ============================================ // Main // ============================================ async function main() { const C = { RED: '\x1b[0;31m', GREEN: '\x1b[0;32m', YELLOW: '\x1b[1;33m', CYAN: '\x1b[0;36m', BOLD: '\x1b[1m', NC: '\x1b[0m', }; console.log(`\n${C.CYAN}${C.BOLD}=========================================${C.NC}`); console.log(`${C.CYAN}${C.BOLD} Wordly.art - Setup Wizard${C.NC}`); console.log(`${C.CYAN}${C.BOLD}=========================================${C.NC}\n`); // Check we're in the right directory if (!fs.existsSync(path.join(__dirname, '..', 'docker-compose.yml'))) { console.log(`${C.RED}Erreur: lance ce script depuis la racine du projet${C.NC}`); console.log(`Usage: node scripts/setup.js`); process.exit(1); } // Read existing .env if present const existing = readEnv(); // ===== 1. Domaine ===== console.log(`\n${C.BOLD}--- Domaine ---${C.NC}`); const domain = await question('Nom de domaine', existing.DOMAIN || 'wordly.art'); // ===== 2. Admin ===== console.log(`\n${C.BOLD}--- Admin ---${C.NC}`); const adminUser = await question('Nom d\'utilisateur admin', existing.ADMIN_USERNAME || 'admin'); const adminPass = await questionPasswordSimple('Mot de passe admin'); if (!adminPass) { console.log(`${C.RED}Mot de passe obligatoire!${C.NC}`); process.exit(1); } const adminPass2 = await questionPasswordSimple('Confirmer'); if (adminPass !== adminPass2) { console.log(`${C.RED}Ne correspondent pas!${C.NC}`); process.exit(1); } console.log(`${C.GREEN}OK${C.NC}`); // Hash bcrypt console.log(`${C.CYAN}Hashage bcrypt...${C.NC}`); const adminHash = hashBcrypt(adminPass); // ===== 3. Secrets auto ===== console.log(`\n${C.BOLD}--- Secrets (auto) ---${C.NC}`); const jwtSecret = generateSecret(64); const adminTokenSecret = generateHex(32); const pgPassword = generateSecret(32); const grafanaPassword = generateSecret(24); console.log(`${C.GREEN}JWT, Admin Token, DB, Grafana generes${C.NC}`); // ===== 4. Provider ===== console.log(`\n${C.BOLD}--- Service de traduction ---${C.NC}`); const providers = [ { value: 'google', label: 'Google (gratuit, aucune cle)' }, { value: 'deepseek', label: 'DeepSeek (~0.14$/M tokens, tres bon)' }, { value: 'minimax', label: 'Minimax (MiniMax-M1 / m2.7)' }, { value: 'ollama', label: 'Ollama (local, gratuit)' }, { value: 'deepl', label: 'DeepL (haute qualite, 500k car/mois gratuit)' }, { value: 'openai', label: 'OpenAI (GPT)' }, { value: 'openrouter', label: 'OpenRouter (multi-modeles)' }, ]; const translationService = await select('Provider par defaut :', providers); // Provider config const prov = { GOOGLE_TRANSLATE_ENABLED: 'true', OLLAMA_ENABLED: 'false', OLLAMA_BASE_URL: 'http://ollama:11434', OLLAMA_MODEL: 'llama3', DEEPSEEK_ENABLED: 'false', DEEPSEEK_API_KEY: '', DEEPSEEK_MODEL: 'deepseek-chat', DEEPSEEK_BASE_URL: 'https://api.deepseek.com/v1', MINIMAX_ENABLED: 'false', MINIMAX_API_KEY: '', MINIMAX_MODEL: 'MiniMax-M1', MINIMAX_BASE_URL: 'https://api.minimax.chat/v1', DEEPL_ENABLED: 'false', DEEPL_API_KEY: '', OPENAI_ENABLED: 'false', OPENAI_API_KEY: '', OPENAI_MODEL: 'gpt-4o-mini', OPENAI_BASE_URL: 'https://api.openai.com/v1', OPENROUTER_ENABLED: 'false', OPENROUTER_API_KEY: '', OPENROUTER_MODEL: 'deepseek/deepseek-chat', }; if (translationService === 'deepseek') { prov.DEEPSEEK_ENABLED = 'true'; prov.DEEPSEEK_API_KEY = await question('Cle API DeepSeek (sk-...)', ''); prov.DEEPSEEK_MODEL = await question('Modele', 'deepseek-chat'); } else if (translationService === 'minimax') { prov.MINIMAX_ENABLED = 'true'; prov.MINIMAX_API_KEY = await question('Cle API Minimax', ''); prov.MINIMAX_MODEL = await question('Modele', 'MiniMax-M1'); } else if (translationService === 'ollama') { prov.OLLAMA_ENABLED = 'true'; prov.OLLAMA_BASE_URL = await question('URL Ollama', 'http://ollama:11434'); prov.OLLAMA_MODEL = await question('Modele', 'llama3'); } else if (translationService === 'deepl') { prov.DEEPL_ENABLED = 'true'; prov.DEEPL_API_KEY = await question('Cle API DeepL', ''); } else if (translationService === 'openai') { prov.OPENAI_ENABLED = 'true'; prov.OPENAI_API_KEY = await question('Cle API OpenAI (sk-...)', ''); prov.OPENAI_MODEL = await question('Modele', 'gpt-4o-mini'); } else if (translationService === 'openrouter') { prov.OPENROUTER_ENABLED = 'true'; prov.OPENROUTER_API_KEY = await question('Cle API OpenRouter (sk-or-...)', ''); prov.OPENROUTER_MODEL = await question('Modele', 'deepseek/deepseek-chat'); } // ===== 5. Stripe (optional) ===== console.log(`\n${C.BOLD}--- Stripe (optionnel) ---${C.NC}`); const doStripe = await question('Configurer Stripe maintenant ? (oui/non)', 'non'); const stripe = { SECRET_KEY: '', WEBHOOK_SECRET: '', STARTER_PRICE_ID: '', PRO_PRICE_ID: '', BUSINESS_PRICE_ID: '' }; if (doStripe === 'oui') { stripe.SECRET_KEY = await question('Secret Key (sk_test_... ou sk_live_...)', ''); stripe.WEBHOOK_SECRET = await question('Webhook Secret (whsec_...)', ''); stripe.STARTER_PRICE_ID = await question('Price ID Starter (price_...)', ''); stripe.PRO_PRICE_ID = await question('Price ID Pro (price_...)', ''); stripe.BUSINESS_PRICE_ID = await question('Price ID Business (price_...)', ''); } // ===== Resume ===== console.log(`\n${C.CYAN}${C.BOLD}=========================================${C.NC}`); console.log(`${C.CYAN}${C.BOLD} RESUME${C.NC}`); console.log(`${C.CYAN}${C.BOLD}=========================================${C.NC}`); console.log(` Domaine: ${domain}`); console.log(` Admin: ${adminUser} / ${adminPass}`); console.log(` Traduction: ${translationService}`); console.log(` Bcrypt hash: ${adminHash ? adminHash.substring(0, 20) + '...' : 'NON GENERE (mot de passe en clair)'}`); console.log(` Stripe: ${stripe.SECRET_KEY ? 'Configure' : 'Non configure'}`); console.log(``); const confirm = await question('Ecrire le .env ? (oui/non)', 'oui'); if (confirm !== 'oui') { console.log('Annule.'); process.exit(0); } // ===== Write .env ===== const lines = [ `# Genere le ${new Date().toISOString().split('T')[0]} par setup.js`, ``, `# ---- App ----`, `APP_NAME=Wordly`, `APP_ENV=production`, `DEBUG=false`, `LOG_LEVEL=INFO`, ``, `# ---- Domaine ----`, `DOMAIN=${domain}`, `NEXT_PUBLIC_API_URL=https://${domain}`, `BACKEND_PORT=8000`, `FRONTEND_PORT=3000`, ``, `# ---- Traduction ----`, `TRANSLATION_SERVICE=${translationService}`, ``, `GOOGLE_TRANSLATE_ENABLED=${prov.GOOGLE_TRANSLATE_ENABLED}`, ``, `OLLAMA_ENABLED=${prov.OLLAMA_ENABLED}`, `OLLAMA_BASE_URL=${prov.OLLAMA_BASE_URL}`, `OLLAMA_MODEL=${prov.OLLAMA_MODEL}`, ``, `DEEPSEEK_ENABLED=${prov.DEEPSEEK_ENABLED}`, `DEEPSEEK_API_KEY=${prov.DEEPSEEK_API_KEY}`, `DEEPSEEK_MODEL=${prov.DEEPSEEK_MODEL}`, `DEEPSEEK_BASE_URL=${prov.DEEPSEEK_BASE_URL}`, ``, `MINIMAX_ENABLED=${prov.MINIMAX_ENABLED}`, `MINIMAX_API_KEY=${prov.MINIMAX_API_KEY}`, `MINIMAX_MODEL=${prov.MINIMAX_MODEL}`, `MINIMAX_BASE_URL=${prov.MINIMAX_BASE_URL}`, ``, `DEEPL_ENABLED=${prov.DEEPL_ENABLED}`, `DEEPL_API_KEY=${prov.DEEPL_API_KEY}`, ``, `OPENAI_ENABLED=${prov.OPENAI_ENABLED}`, `OPENAI_API_KEY=${prov.OPENAI_API_KEY}`, `OPENAI_MODEL=${prov.OPENAI_MODEL}`, `OPENAI_BASE_URL=${prov.OPENAI_BASE_URL}`, ``, `OPENROUTER_ENABLED=${prov.OPENROUTER_ENABLED}`, `OPENROUTER_API_KEY=${prov.OPENROUTER_API_KEY}`, `OPENROUTER_MODEL=${prov.OPENROUTER_MODEL}`, ``, `# ---- Upload ----`, `MAX_FILE_SIZE_MB=50`, `ALLOWED_EXTENSIONS=.docx,.xlsx,.pptx`, ``, `# ---- Rate Limiting ----`, `RATE_LIMIT_ENABLED=true`, `RATE_LIMIT_REQUESTS_PER_MINUTE=60`, `RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10`, `RATE_LIMIT_TRANSLATIONS_PER_HOUR=100`, `RATE_LIMIT_TRANSLATIONS_PER_DAY=500`, ``, `# ---- Admin ----`, `ADMIN_USERNAME=${adminUser}`, `ADMIN_PASSWORD=${adminPass}`, adminHash ? `ADMIN_PASSWORD_HASH=${adminHash}` : `# ADMIN_PASSWORD_HASH= (mot de passe en clair dans ADMIN_PASSWORD)`, `JWT_SECRET_KEY=${jwtSecret}`, `ADMIN_TOKEN_SECRET=${adminTokenSecret}`, `CORS_ORIGINS=https://${domain}`, ``, `# ---- Database ----`, `POSTGRES_USER=translate`, `POSTGRES_PASSWORD=${pgPassword}`, `POSTGRES_DB=translate_db`, ``, `# ---- Monitoring ----`, `GRAFANA_USER=admin`, `GRAFANA_PASSWORD=${grafanaPassword}`, ``, `# ---- Stripe ----`, `STRIPE_SECRET_KEY=${stripe.SECRET_KEY}`, `STRIPE_WEBHOOK_SECRET=${stripe.WEBHOOK_SECRET}`, `STRIPE_STARTER_PRICE_ID=${stripe.STARTER_PRICE_ID}`, `STRIPE_PRO_PRICE_ID=${stripe.PRO_PRICE_ID}`, `STRIPE_BUSINESS_PRICE_ID=${stripe.BUSINESS_PRICE_ID}`, ``, ]; writeEnv(lines.join('\n')); console.log(`${C.GREEN}.env ecrit!${C.NC}`); // ===== Offer to start ===== console.log(`\n${C.BOLD}Prochaines etapes :${C.NC}`); console.log(` 1. Verifier: cat .env`); console.log(` 2. Lancer: docker compose up -d --build`); console.log(` 3. Tester: curl http://localhost:8000/health`); console.log(``); const doStart = await question('Lancer docker compose maintenant ? (oui/non)', 'non'); if (doStart === 'oui') { console.log(`${C.CYAN}Build et lancement...${C.NC}`); try { execSync('docker compose up -d --build', { stdio: 'inherit', cwd: path.join(__dirname, '..'), timeout: 600000 }); console.log(`${C.GREEN}Demarre!${C.NC}`); console.log(`\nVerification...`); try { const health = execSync('curl -sf http://localhost:8000/health', { encoding: 'utf-8', timeout: 10000 }); console.log(` Health: ${health}`); } catch { console.log(` ${C.YELLOW}Health check pas encore pret, attends 30s puis: curl http://localhost:8000/health${C.NC}`); } execSync('docker compose ps', { stdio: 'inherit', cwd: path.join(__dirname, '..') }); } catch (e) { console.log(`${C.RED}Erreur docker. Verifie les logs: docker compose logs${C.NC}`); } } console.log(`\n${C.GREEN}${C.BOLD}Setup termine!${C.NC}`); console.log(`Admin: ${adminUser} / ${adminPass}`); console.log(`Grafana: admin / ${grafanaPassword}`); } main().catch(e => { console.error(`\nErreur: ${e.message}`); process.exit(1); });