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 <noreply@anthropic.com>
391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
#!/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);
|
|
});
|