From 98d82414bb7950941ee116ace4ad8072ba2343d8 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sun, 10 May 2026 13:29:28 +0200 Subject: [PATCH] feat: add Node.js setup wizard (replaces broken bash scripts) Node.js script that actually works: - Generates JWT_SECRET_KEY, ADMIN_TOKEN_SECRET, POSTGRES_PASSWORD - Hashes admin password with bcrypt via docker - Writes ADMIN_PASSWORD (plaintext) + ADMIN_PASSWORD_HASH (bcrypt) - Sets *_ENABLED=true for chosen provider, false for others - Writes ALL provider fields (api_key, model, base_url) - Optionally configures Stripe - Optionally starts docker compose - Verified: syntax OK, all env vars match backend expectations Usage: node scripts/setup.js Co-Authored-By: Claude Opus 4.7 --- scripts/setup.js | 390 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 scripts/setup.js diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000..889c35f --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,390 @@ +#!/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); +});