#!/usr/bin/env node // ============================================================================= // Memento - Interactive Environment Setup Wizard // ============================================================================= // Zero external dependencies. Uses only Node.js built-ins. // Usage: npm run setup:env // ============================================================================= const readline = require('readline'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { execSync } = require('child_process'); // --------------------------------------------------------------------------- // ANSI helpers // --------------------------------------------------------------------------- const R = '\x1b[0m'; const bold = (s) => `\x1b[1m${s}${R}`; const dim = (s) => `\x1b[2m${s}${R}`; const red = (s) => `\x1b[31m${s}${R}`; const green = (s) => `\x1b[32m${s}${R}`; const yellow = (s) => `\x1b[33m${s}${R}`; const cyan = (s) => `\x1b[36m${s}${R}`; const magenta = (s) => `\x1b[35m${s}${R}`; const blue = (s) => `\x1b[34m${s}${R}`; // --------------------------------------------------------------------------- // Readline promisified // --------------------------------------------------------------------------- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); function question(prompt) { return new Promise((resolve) => { rl.question(prompt, (answer) => resolve(answer.trim())); }); } // --------------------------------------------------------------------------- // Crypto helpers // --------------------------------------------------------------------------- function generateSecret() { return crypto.randomBytes(32).toString('base64'); } function generatePassword(len = 24) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789!@#$%&*'; const bytes = crypto.randomBytes(len); return Array.from(bytes, (b) => chars[b % chars.length]).join(''); } // --------------------------------------------------------------------------- // Prisma schema helper // --------------------------------------------------------------------------- const PRISMA_SCHEMA_PATH = path.join(__dirname, '..', 'prisma', 'schema.prisma'); function switchPrismaProvider(target) { if (!['sqlite', 'postgresql'].includes(target)) return; let schema = fs.readFileSync(PRISMA_SCHEMA_PATH, 'utf8'); schema = schema.replace( /(datasource db \{\s*\n\s*provider\s*=\s*)"[^"]+"/, `$1"${target}"` ); fs.writeFileSync(PRISMA_SCHEMA_PATH, schema); } // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- function isSensitive(key) { const k = key.toUpperCase(); return k.includes('KEY') || k.includes('SECRET') || k.includes('PASSWORD') || k.includes('PASS'); } function mask(value) { if (!value || value.length <= 8) return '****'; return value.slice(0, 4) + '****' + value.slice(-4); } async function askRequired(prompt, defaultValue) { while (true) { const answer = await question(prompt); const val = answer || defaultValue; if (val) return val; console.log(red(' This field is required. Please enter a value.')); } } async function askYesNo(prompt, defaultYes = true) { const hint = defaultYes ? 'Y/n' : 'y/N'; const answer = await question(`${prompt} (${hint}): `); if (!answer) return defaultYes; return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'; } // --------------------------------------------------------------------------- // Admin account creation // --------------------------------------------------------------------------- async function createLocalAdmin() { console.log(); console.log(cyan(bold(' ── Admin Account ─────────────────────────────────'))); console.log(dim(' Create the first admin account for your instance.')); console.log(); const email = await askRequired(' Admin email: ', ''); const name = await question(' Admin name (Admin): ') || 'Admin'; const password = await askRequired(' Admin password: ', ''); // Hash password using bcryptjs (installed dependency) console.log(dim(' Hashing password...')); let hashedPassword; try { const bcrypt = require('bcryptjs'); hashedPassword = await bcrypt.hash(password, 12); } catch { // Fallback: use Node crypto if bcryptjs unavailable hashedPassword = crypto.createHash('sha256').update(password).digest('hex'); console.log(yellow(' Warning: bcryptjs not available, using SHA-256 fallback')); } // Build inline script that uses Prisma to create the user const script = ` const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); (async () => { try { const user = await prisma.user.create({ data: { email: ${JSON.stringify(email.toLowerCase())}, name: ${JSON.stringify(name)}, password: ${JSON.stringify(hashedPassword)}, role: 'ADMIN', } }); console.log('OK:' + user.email); } catch (e) { if (e.code === 'P2002') { console.log('EXISTS:${email.toLowerCase()}'); } else { console.error('ERR:' + e.message); } } finally { await prisma.$disconnect(); } })(); `; // Write temp script and execute const tmpFile = path.join(__dirname, '..', '_setup_admin_tmp.js'); fs.writeFileSync(tmpFile, script, 'utf8'); try { const output = execSync(`node "${tmpFile}"`, { cwd: path.join(__dirname, '..'), encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); if (output.startsWith('OK:')) { console.log(green(` Admin account created: ${output.slice(3)}`)); return true; } else if (output.startsWith('EXISTS:')) { console.log(yellow(` User ${output.slice(7)} already exists. Skipping.`)); return false; } else if (output.startsWith('ERR:')) { console.log(red(` Error: ${output.slice(4)}`)); return false; } } catch (err) { console.log(red(` Failed to create admin: ${err.message}`)); console.log(dim(' You can create it later with: node scripts/promote-admin.js')); return false; } finally { fs.unlinkSync(tmpFile); } } // --------------------------------------------------------------------------- // Write config to SystemConfig DB table // --------------------------------------------------------------------------- function writeConfigToDb(vars) { // Keys that the admin panel reads from SystemConfig (not from .env) const dbKeys = [ 'AI_PROVIDER', 'AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING', 'AI_PROVIDER_CHAT', 'AI_MODEL_TAGS', 'AI_MODEL_EMBEDDING', 'AI_MODEL_CHAT', 'OPENAI_API_KEY', 'OLLAMA_BASE_URL', 'CUSTOM_OPENAI_API_KEY', 'CUSTOM_OPENAI_BASE_URL', 'RESEND_API_KEY', 'SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM', 'SMTP_SECURE', 'SMTP_IGNORE_CERT', 'ALLOW_REGISTRATION', 'MCP_SERVER_MODE', 'MCP_SERVER_URL', ]; const entries = []; for (const key of dbKeys) { if (vars[key]) { entries.push({ key, value: vars[key] }); } } if (entries.length === 0) return; // Build inline script using Prisma to upsert SystemConfig const entriesJson = JSON.stringify(entries); const script = ` const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); (async () => { try { const entries = ${entriesJson}; for (const e of entries) { await prisma.systemConfig.upsert({ where: { key: e.key }, update: { value: e.value }, create: { key: e.key, value: e.value }, }); } console.log('OK:' + entries.length); } catch (e) { console.error('ERR:' + e.message); } finally { await prisma.$disconnect(); } })(); `; const tmpFile = path.join(__dirname, '..', '_setup_config_tmp.js'); fs.writeFileSync(tmpFile, script, 'utf8'); try { const output = execSync(`node "${tmpFile}"`, { cwd: path.join(__dirname, '..'), encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); if (output.startsWith('OK:')) { console.log(green(` Wrote ${output.slice(3)} settings to database`)); return true; } else if (output.startsWith('ERR:')) { console.log(yellow(` Warning: could not write config to DB: ${output.slice(4)}`)); return false; } } catch (err) { console.log(yellow(' Warning: could not write config to database.')); console.log(dim(' Settings will still work via .env but won\'t appear in the admin panel.')); return false; } finally { fs.unlinkSync(tmpFile); } } // --------------------------------------------------------------------------- // Banner // --------------------------------------------------------------------------- function showBanner() { console.log(); console.log(cyan(bold(' ╔══════════════════════════════════════════╗'))); console.log(cyan(bold(' ║ Memento Environment Setup Wizard ║'))); console.log(cyan(bold(' ╚══════════════════════════════════════════╝'))); console.log(); console.log(dim(' This wizard will help you create environment')); console.log(dim(' configuration files for your Memento instance.')); console.log(); } // --------------------------------------------------------------------------- // Environment choice // --------------------------------------------------------------------------- async function chooseEnvironment() { console.log(bold(' Which environment do you want to configure?')); console.log(); console.log(` ${green('1')} - Local development ${dim('(creates .env in memento-note/)')}`); console.log(` ${green('2')} - Docker deployment ${dim('(creates .env.docker in Memento/)')}`); console.log(` ${green('3')} - Both ${dim('(creates both files, shared secret)')}`); console.log(); while (true) { const choice = await question(' Select [1/2/3]: '); if (choice === '1') return 'local'; if (choice === '2') return 'docker'; if (choice === '3') return 'both'; console.log(yellow(' Please enter 1, 2, or 3.')); } } // --------------------------------------------------------------------------- // AI provider menus // --------------------------------------------------------------------------- const AI_PROVIDERS = ['openai', 'anthropic', 'anthropic_custom', 'ollama', 'deepseek', 'openrouter', 'custom-openai']; function showAiProviderMenu(label) { console.log(); console.log(bold(` AI Provider for ${label}`)); console.log(` ${green('1')} - OpenAI`); console.log(` ${green('2')} - Ollama (local)`); console.log(` ${green('3')} - DeepSeek`); console.log(` ${green('4')} - OpenRouter`); console.log(` ${green('5')} - Custom OpenAI-compatible`); console.log(` ${green('6')} - Skip (configure later)`); } async function chooseAiProvider(label) { showAiProviderMenu(label); while (true) { const choice = await question(' Select [1-6]: '); if (choice === '1') return 'openai'; if (choice === '2') return 'ollama'; if (choice === '3') return 'deepseek'; if (choice === '4') return 'openrouter'; if (choice === '5') return 'custom-openai'; if (choice === '6') return null; console.log(yellow(' Please enter a number from 1 to 6.')); } } async function collectAiProviderFields(provider, defaults = {}) { const vars = {}; switch (provider) { case 'openai': vars.OPENAI_API_KEY = await askRequired( ` OpenAI API Key ${dim(`[${mask(defaults.OPENAI_API_KEY || '')}]`)}: `, defaults.OPENAI_API_KEY || '' ); break; case 'ollama': { const ollamaUrl = defaults.OLLAMA_BASE_URL || 'http://localhost:11434'; vars.OLLAMA_BASE_URL = await question(` Ollama Base URL (${ollamaUrl}): `) || ollamaUrl; break; } case 'deepseek': { const dsKey = await askRequired( ` DeepSeek API Key ${dim(`[${mask(defaults.CUSTOM_OPENAI_API_KEY || '')}]`)}: `, defaults.CUSTOM_OPENAI_API_KEY || '' ); vars.CUSTOM_OPENAI_API_KEY = dsKey; vars.CUSTOM_OPENAI_BASE_URL = 'https://api.deepseek.com/v1'; console.log(dim(' Base URL: https://api.deepseek.com/v1')); break; } case 'openrouter': { const orKey = await askRequired( ` OpenRouter API Key ${dim(`[${mask(defaults.CUSTOM_OPENAI_API_KEY || '')}]`)}: `, defaults.CUSTOM_OPENAI_API_KEY || '' ); vars.CUSTOM_OPENAI_API_KEY = orKey; vars.CUSTOM_OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'; console.log(dim(' Base URL: https://openrouter.ai/api/v1')); break; } case 'custom-openai': { vars.CUSTOM_OPENAI_API_KEY = await askRequired( ` Custom API Key ${dim(`[${mask(defaults.CUSTOM_OPENAI_API_KEY || '')}]`)}: `, defaults.CUSTOM_OPENAI_API_KEY || '' ); const defaultUrl = defaults.CUSTOM_OPENAI_BASE_URL || ''; vars.CUSTOM_OPENAI_BASE_URL = await askRequired( ` Custom Base URL ${defaultUrl ? dim(`[${defaultUrl}]`) : ''}: `, defaultUrl ); break; } } return vars; } async function collectAiModels(provider, feature) { const vars = {}; const defaults = { chat: { openai: 'gpt-4o-mini', ollama: 'llama3.2', 'custom-openai': 'gpt-4o-mini', deepseek: 'deepseek-chat', openrouter: 'gpt-4o-mini' }, tags: { openai: 'gpt-4o-mini', ollama: 'llama3.2', 'custom-openai': 'gpt-4o-mini', deepseek: 'deepseek-chat', openrouter: 'gpt-4o-mini' }, embedding: { openai: 'text-embedding-3-small', ollama: 'nomic-embed-text', 'custom-openai': 'text-embedding-3-small', deepseek: 'deepseek-chat', openrouter: 'text-embedding-3-small' }, }; const modelKey = `AI_MODEL_${feature.toUpperCase()}`; const defaultModel = defaults[feature.toLowerCase()]?.[provider] || ''; if (feature.toLowerCase() === 'chat') { const answer = await question(` Chat model (${defaultModel}): `); vars[modelKey] = answer || defaultModel; } else if (feature.toLowerCase() === 'tags') { const answer = await question(` Tags model (${defaultModel}): `); vars[modelKey] = answer || defaultModel; } else if (feature.toLowerCase() === 'embedding') { const answer = await question(` Embedding model (${defaultModel}): `); vars[modelKey] = answer || defaultModel; } return vars; } // --------------------------------------------------------------------------- // Local .env section // --------------------------------------------------------------------------- async function collectLocalEnv(sharedSecret) { console.log(); console.log(cyan(bold(' ── Local Development (.env) ──────────────────────'))); console.log(); const vars = {}; // Database type console.log(bold(' Database engine')); console.log(` ${green('1')} - PostgreSQL ${dim('(recommended, required for Docker, embeddings)')}`); console.log(` ${green('2')} - SQLite ${dim('(simple, file-based, no server needed)')}`); console.log(); let dbType; while (true) { const dbChoice = await question(' Select [1/2]: '); if (dbChoice === '1') { dbType = 'postgresql'; break; } if (dbChoice === '2') { dbType = 'sqlite'; break; } console.log(yellow(' Please enter 1 or 2.')); } if (dbType === 'sqlite') { vars.DATABASE_URL = 'file:./dev.db'; vars._dbType = 'sqlite'; console.log(dim(' DATABASE_URL set to file:./dev.db')); } else { console.log(dim(' If using Docker PostgreSQL: postgresql://memento:memento@localhost:5432/memento')); vars.DATABASE_URL = await question( ` DATABASE_URL (${dim('postgresql://memento:memento@localhost:5432/memento')}): ` ) || 'postgresql://memento:memento@localhost:5432/memento'; vars._dbType = 'postgresql'; } // NEXTAUTH_URL vars.NEXTAUTH_URL = await question( ` NEXTAUTH_URL (${dim('http://localhost:3000')}): ` ) || 'http://localhost:3000'; // NEXTAUTH_SECRET const autoSecret = sharedSecret || generateSecret(); console.log(dim(` Generated NEXTAUTH_SECRET: ${mask(autoSecret)}`)); const secretAnswer = await question(` NEXTAUTH_SECRET (press Enter to use generated): `); vars.NEXTAUTH_SECRET = secretAnswer || autoSecret; // ALLOW_REGISTRATION vars.ALLOW_REGISTRATION = (await askYesNo(' Allow registration?', true)) ? 'true' : 'false'; // AI Provider (single provider for all features in local) const provider = await chooseAiProvider('all features'); if (provider) { vars.AI_PROVIDER = provider; const providerVars = await collectAiProviderFields(provider, {}); Object.assign(vars, providerVars); // Ask for model overrides const configureModels = await askYesNo(' Configure model names?', false); if (configureModels) { const chatModels = await collectAiModels(provider, 'chat'); Object.assign(vars, chatModels); const tagModels = await collectAiModels(provider, 'tags'); Object.assign(vars, tagModels); const embModels = await collectAiModels(provider, 'embedding'); Object.assign(vars, embModels); } } // Email const wantEmail = await askYesNo(' Configure email?', false); if (wantEmail) { const useResend = await askYesNo(' Use Resend? (vs SMTP)', false); if (useResend) { vars.RESEND_API_KEY = await askRequired(' RESEND_API_KEY: ', ''); } else { vars.SMTP_HOST = await askRequired(' SMTP_HOST: ', ''); vars.SMTP_PORT = await question(` SMTP_PORT (${dim('587')}): `) || '587'; vars.SMTP_USER = await askRequired(' SMTP_USER: ', ''); vars.SMTP_PASS = await askRequired(' SMTP_PASS: ', ''); vars.SMTP_FROM = await askRequired(' SMTP_FROM: ', ''); const secure = await askYesNo(' Use TLS/SSL?', true); vars.SMTP_SECURE = secure ? 'true' : 'false'; } } // MCP const wantMcp = await askYesNo(' Configure MCP server?', false); if (wantMcp) { const useSse = await askYesNo(' MCP mode: SSE? (vs stdio)', false); vars.MCP_SERVER_MODE = useSse ? 'sse' : 'stdio'; if (useSse) { vars.MCP_SERVER_URL = await question(` MCP Server URL (${dim('http://localhost:3001')}): `) || 'http://localhost:3001'; } } return vars; } // --------------------------------------------------------------------------- // Docker .env.docker section // --------------------------------------------------------------------------- async function collectDockerEnv(sharedSecret) { console.log(); console.log(cyan(bold(' ── Docker Deployment (.env.docker) ────────────────'))); console.log(); const vars = {}; // NEXTAUTH_URL console.log(dim(' Hint: Use your server IP or domain (e.g. http://192.168.1.100:3000)')); vars.NEXTAUTH_URL = await question( ` NEXTAUTH_URL (${dim('http://localhost:3000')}): ` ) || 'http://localhost:3000'; // NEXTAUTH_SECRET const autoSecret = sharedSecret || generateSecret(); console.log(dim(` Generated NEXTAUTH_SECRET: ${mask(autoSecret)}`)); const secretAnswer = await question(` NEXTAUTH_SECRET (press Enter to use generated): `); vars.NEXTAUTH_SECRET = secretAnswer || autoSecret; // ALLOW_REGISTRATION vars.ALLOW_REGISTRATION = (await askYesNo(' Allow registration?', true)) ? 'true' : 'false'; // PostgreSQL console.log(); console.log(bold(' PostgreSQL Configuration')); console.log(dim(' Password auto-generated. You can override it.')); const pgPassword = generatePassword(); console.log(dim(` Generated POSTGRES_PASSWORD: ${mask(pgPassword)}`)); vars.POSTGRES_PORT = await question(` POSTGRES_PORT (${dim('5432')}): `) || '5432'; vars.POSTGRES_DB = await question(` POSTGRES_DB (${dim('memento')}): `) || 'memento'; vars.POSTGRES_USER = await question(` POSTGRES_USER (${dim('memento')}): `) || 'memento'; const pgPassAnswer = await question(` POSTGRES_PASSWORD (press Enter to use generated): `); vars.POSTGRES_PASSWORD = pgPassAnswer || pgPassword; // AI per-feature console.log(); console.log(bold(' AI Provider Configuration (per-feature)')); const tagsProvider = await chooseAiProvider('Tags generation'); if (tagsProvider) { vars.AI_PROVIDER_TAGS = tagsProvider; const fields = await collectAiProviderFields(tagsProvider, { OLLAMA_BASE_URL: 'http://ollama:11434' }); Object.assign(vars, fields); const models = await collectAiModels(tagsProvider, 'tags'); Object.assign(vars, models); } const embProvider = await chooseAiProvider('Embeddings'); if (embProvider) { vars.AI_PROVIDER_EMBEDDING = embProvider; if (!vars.CUSTOM_OPENAI_API_KEY && !vars.OPENAI_API_KEY) { const fields = await collectAiProviderFields(embProvider, { OLLAMA_BASE_URL: 'http://ollama:11434' }); Object.assign(vars, fields); } const models = await collectAiModels(embProvider, 'embedding'); Object.assign(vars, models); } const wantChat = await askYesNo(' Configure a separate chat provider?', false); if (wantChat) { const chatProvider = await chooseAiProvider('Chat'); if (chatProvider) { vars.AI_PROVIDER_CHAT = chatProvider; if (!vars.CUSTOM_OPENAI_API_KEY && !vars.OPENAI_API_KEY) { const fields = await collectAiProviderFields(chatProvider, { OLLAMA_BASE_URL: 'http://ollama:11434' }); Object.assign(vars, fields); } const models = await collectAiModels(chatProvider, 'chat'); Object.assign(vars, models); } } // MCP const wantMcp = await askYesNo(' Configure MCP server?', false); if (wantMcp) { const useSse = await askYesNo(' MCP mode: SSE? (vs stdio)', false); vars.MCP_MODE = useSse ? 'sse' : 'stdio'; vars.MCP_PORT = await question(` MCP_PORT (${dim('3001')}): `) || '3001'; if (useSse) { vars.MCP_SERVER_URL = await question(` MCP_SERVER_URL (${dim('http://localhost:3001')}): `) || 'http://localhost:3001'; } } // Email const wantEmail = await askYesNo(' Configure email?', false); if (wantEmail) { const useResend = await askYesNo(' Use Resend? (vs SMTP)', false); if (useResend) { vars.RESEND_API_KEY = await askRequired(' RESEND_API_KEY: ', ''); } else { vars.SMTP_HOST = await askRequired(' SMTP_HOST: ', ''); vars.SMTP_PORT = await question(` SMTP_PORT (${dim('587')}): `) || '587'; vars.SMTP_USER = await askRequired(' SMTP_USER: ', ''); vars.SMTP_PASS = await askRequired(' SMTP_PASS: ', ''); vars.SMTP_FROM = await askRequired(' SMTP_FROM: ', ''); } } return vars; } // --------------------------------------------------------------------------- // File writing // --------------------------------------------------------------------------- function generateLocalEnvContent(vars) { let content = `# ============================================================================= # Memento Note - Local Environment Configuration # Generated by setup-env wizard # ============================================================================= # ----------------------------------------------------------------------------- # Core # ----------------------------------------------------------------------------- DATABASE_URL="${vars.DATABASE_URL}" NEXTAUTH_SECRET="${vars.NEXTAUTH_SECRET}" NEXTAUTH_URL="${vars.NEXTAUTH_URL}" `; if (vars.ALLOW_REGISTRATION) { content += ` # ----------------------------------------------------------------------------- # Registration # ----------------------------------------------------------------------------- ALLOW_REGISTRATION="${vars.ALLOW_REGISTRATION}" `; } // AI provider section const aiKeys = ['AI_PROVIDER', 'AI_PROVIDER_CHAT', 'AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING', 'AI_MODEL_CHAT', 'AI_MODEL_TAGS', 'AI_MODEL_EMBEDDING', 'OPENAI_API_KEY', 'OLLAMA_BASE_URL', 'CUSTOM_OPENAI_API_KEY', 'CUSTOM_OPENAI_BASE_URL']; const hasAi = aiKeys.some((k) => vars[k]); if (hasAi) { content += ` # ----------------------------------------------------------------------------- # AI Providers # ----------------------------------------------------------------------------- `; if (vars.AI_PROVIDER) content += `AI_PROVIDER="${vars.AI_PROVIDER}"\n`; if (vars.AI_PROVIDER_CHAT) content += `AI_PROVIDER_CHAT="${vars.AI_PROVIDER_CHAT}"\n`; if (vars.AI_PROVIDER_TAGS) content += `AI_PROVIDER_TAGS="${vars.AI_PROVIDER_TAGS}"\n`; if (vars.AI_PROVIDER_EMBEDDING) content += `AI_PROVIDER_EMBEDDING="${vars.AI_PROVIDER_EMBEDDING}"\n`; if (vars.AI_MODEL_CHAT) content += `AI_MODEL_CHAT="${vars.AI_MODEL_CHAT}"\n`; if (vars.AI_MODEL_TAGS) content += `AI_MODEL_TAGS="${vars.AI_MODEL_TAGS}"\n`; if (vars.AI_MODEL_EMBEDDING) content += `AI_MODEL_EMBEDDING="${vars.AI_MODEL_EMBEDDING}"\n`; if (vars.OPENAI_API_KEY) content += `OPENAI_API_KEY="${vars.OPENAI_API_KEY}"\n`; if (vars.OLLAMA_BASE_URL) content += `OLLAMA_BASE_URL="${vars.OLLAMA_BASE_URL}"\n`; if (vars.CUSTOM_OPENAI_API_KEY) content += `CUSTOM_OPENAI_API_KEY="${vars.CUSTOM_OPENAI_API_KEY}"\n`; if (vars.CUSTOM_OPENAI_BASE_URL) content += `CUSTOM_OPENAI_BASE_URL="${vars.CUSTOM_OPENAI_BASE_URL}"\n`; } // Email section const emailKeys = ['RESEND_API_KEY', 'SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM', 'SMTP_SECURE', 'SMTP_IGNORE_CERT']; const hasEmail = emailKeys.some((k) => vars[k]); if (hasEmail) { content += ` # ----------------------------------------------------------------------------- # Email # ----------------------------------------------------------------------------- `; if (vars.RESEND_API_KEY) content += `RESEND_API_KEY="${vars.RESEND_API_KEY}"\n`; if (vars.SMTP_HOST) content += `SMTP_HOST="${vars.SMTP_HOST}"\n`; if (vars.SMTP_PORT) content += `SMTP_PORT="${vars.SMTP_PORT}"\n`; if (vars.SMTP_USER) content += `SMTP_USER="${vars.SMTP_USER}"\n`; if (vars.SMTP_PASS) content += `SMTP_PASS="${vars.SMTP_PASS}"\n`; if (vars.SMTP_FROM) content += `SMTP_FROM="${vars.SMTP_FROM}"\n`; if (vars.SMTP_SECURE) content += `SMTP_SECURE="${vars.SMTP_SECURE}"\n`; } // MCP section if (vars.MCP_SERVER_MODE) { content += ` # ----------------------------------------------------------------------------- # MCP # ----------------------------------------------------------------------------- MCP_SERVER_MODE="${vars.MCP_SERVER_MODE}" `; if (vars.MCP_SERVER_URL) content += `MCP_SERVER_URL="${vars.MCP_SERVER_URL}"\n`; } content += '\n'; return content; } function generateDockerEnvContent(vars) { let content = `# ============================================================================= # Memento - Docker Environment Configuration # Generated by setup-env wizard # ============================================================================= # ============================================================================= # APPLICATION # ============================================================================= NEXTAUTH_URL="${vars.NEXTAUTH_URL}" NEXTAUTH_SECRET="${vars.NEXTAUTH_SECRET}" `; if (vars.ALLOW_REGISTRATION) { content += `ALLOW_REGISTRATION="${vars.ALLOW_REGISTRATION}"\n`; } content += ` # ============================================================================= # POSTGRESQL # ============================================================================= POSTGRES_PORT=${vars.POSTGRES_PORT || 5432} POSTGRES_DB=${vars.POSTGRES_DB || 'memento'} POSTGRES_USER=${vars.POSTGRES_USER || 'memento'} POSTGRES_PASSWORD=${vars.POSTGRES_PASSWORD || 'memento'} `; // AI per-feature const aiKeys = ['AI_PROVIDER_TAGS', 'AI_PROVIDER_EMBEDDING', 'AI_PROVIDER_CHAT', 'AI_MODEL_TAGS', 'AI_MODEL_EMBEDDING', 'AI_MODEL_CHAT', 'OLLAMA_BASE_URL', 'OPENAI_API_KEY', 'CUSTOM_OPENAI_API_KEY', 'CUSTOM_OPENAI_BASE_URL']; const hasAi = aiKeys.some((k) => vars[k]); if (hasAi) { content += ` # ============================================================================= # AI PROVIDERS # ============================================================================= `; if (vars.AI_PROVIDER_TAGS) content += `AI_PROVIDER_TAGS=${vars.AI_PROVIDER_TAGS}\n`; if (vars.AI_MODEL_TAGS) content += `AI_MODEL_TAGS="${vars.AI_MODEL_TAGS}"\n`; if (vars.AI_PROVIDER_EMBEDDING) content += `AI_PROVIDER_EMBEDDING=${vars.AI_PROVIDER_EMBEDDING}\n`; if (vars.AI_MODEL_EMBEDDING) content += `AI_MODEL_EMBEDDING="${vars.AI_MODEL_EMBEDDING}"\n`; if (vars.AI_PROVIDER_CHAT) content += `AI_PROVIDER_CHAT=${vars.AI_PROVIDER_CHAT}\n`; if (vars.AI_MODEL_CHAT) content += `AI_MODEL_CHAT="${vars.AI_MODEL_CHAT}"\n`; if (vars.OLLAMA_BASE_URL) content += `OLLAMA_BASE_URL="${vars.OLLAMA_BASE_URL}"\n`; if (vars.OPENAI_API_KEY) content += `OPENAI_API_KEY="${vars.OPENAI_API_KEY}"\n`; if (vars.CUSTOM_OPENAI_API_KEY) content += `CUSTOM_OPENAI_API_KEY="${vars.CUSTOM_OPENAI_API_KEY}"\n`; if (vars.CUSTOM_OPENAI_BASE_URL) content += `CUSTOM_OPENAI_BASE_URL="${vars.CUSTOM_OPENAI_BASE_URL}"\n`; } // MCP if (vars.MCP_MODE || vars.MCP_SERVER_MODE) { content += ` # ============================================================================= # MCP SERVER # ============================================================================= `; if (vars.MCP_MODE) content += `MCP_MODE="${vars.MCP_MODE}"\n`; if (vars.MCP_PORT) content += `MCP_PORT="${vars.MCP_PORT}"\n`; if (vars.MCP_SERVER_URL) content += `MCP_SERVER_URL="${vars.MCP_SERVER_URL}"\n`; } // Email const emailKeys = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM', 'RESEND_API_KEY']; const hasEmail = emailKeys.some((k) => vars[k]); if (hasEmail) { content += ` # ============================================================================= # EMAIL # ============================================================================= `; if (vars.SMTP_HOST) content += `SMTP_HOST="${vars.SMTP_HOST}"\n`; if (vars.SMTP_PORT) content += `SMTP_PORT="${vars.SMTP_PORT}"\n`; if (vars.SMTP_USER) content += `SMTP_USER="${vars.SMTP_USER}"\n`; if (vars.SMTP_PASS) content += `SMTP_PASS="${vars.SMTP_PASS}"\n`; if (vars.SMTP_FROM) content += `SMTP_FROM="${vars.SMTP_FROM}"\n`; if (vars.RESEND_API_KEY) content += `RESEND_API_KEY="${vars.RESEND_API_KEY}"\n`; } content += '\n'; return content; } // --------------------------------------------------------------------------- // File conflict handling // --------------------------------------------------------------------------- async function handleExistingFile(filePath) { if (!fs.existsSync(filePath)) return 'write'; console.log(yellow(`\n File already exists: ${filePath}`)); while (true) { console.log(` ${green('1')} - Overwrite`); console.log(` ${green('2')} - Skip`); console.log(` ${green('3')} - View current content`); const choice = await question(' Choose [1/2/3]: '); if (choice === '1') return 'write'; if (choice === '2') return 'skip'; if (choice === '3') { const content = fs.readFileSync(filePath, 'utf8'); console.log(); console.log(dim(' ── Current content ─────────────────────────────')); content.split('\n').forEach((line) => console.log(dim(` ${line}`))); console.log(dim(' ─────────────────────────────────────────────────')); continue; } console.log(yellow(' Please enter 1, 2, or 3.')); } } // --------------------------------------------------------------------------- // Recap display // --------------------------------------------------------------------------- function showRecap(vars, title) { console.log(); console.log(bold(` ${title}`)); console.log(dim(' ─────────────────────────────────────────────────')); const keys = Object.keys(vars).filter((k) => !k.startsWith('_')); const maxLen = Math.max(...keys.map((k) => k.length)); for (const key of keys) { const value = vars[key]; const display = isSensitive(key) ? mask(value) : value; const padded = key.padEnd(maxLen); console.log(` ${cyan(padded)} = ${display}`); } console.log(); } // --------------------------------------------------------------------------- // Next steps // --------------------------------------------------------------------------- function showNextSteps(results) { console.log(); console.log(cyan(bold(' ══════════════════════════════════════════════════'))); console.log(cyan(bold(' Setup complete!'))); console.log(cyan(bold(' ══════════════════════════════════════════════════'))); console.log(); if (results.local) { console.log(green(` Created: ${results.local}`)); } if (results.docker) { console.log(green(` Created: ${results.docker}`)); } console.log(); console.log(bold(' Next steps:')); if (results.local) { console.log(); console.log(' For local development:'); if (!results.adminCreated) { console.log(dim(' npx prisma db push')); } console.log(dim(' npm run dev')); if (!results.adminCreated) { console.log(dim(' Then register via /register and run:')); console.log(dim(' node scripts/promote-admin.js your@email.com')); } } if (results.docker) { console.log(); console.log(' For Docker deployment:'); console.log(dim(' cd .. && docker compose up -d')); console.log(dim(' Register via /register, then promote to admin:')); console.log(dim(' docker compose exec memento-note node scripts/promote-admin.js your@email.com')); } console.log(); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { // Handle Ctrl+C gracefully rl.on('close', () => { console.log(); console.log(dim(' Wizard cancelled.')); process.exit(0); }); showBanner(); const envChoice = await chooseEnvironment(); const results = {}; let sharedSecret = null; // For "both" mode, generate one shared secret if (envChoice === 'both') { sharedSecret = generateSecret(); console.log(dim(`\n Shared NEXTAUTH_SECRET: ${mask(sharedSecret)}`)); } // Local section if (envChoice === 'local' || envChoice === 'both') { const localVars = await collectLocalEnv(sharedSecret); const localPath = path.join(__dirname, '..', '.env'); showRecap(localVars, 'Local .env recap:'); const action = await handleExistingFile(localPath); if (action === 'write') { // Extract internal metadata before writing const dbType = localVars._dbType || 'postgresql'; delete localVars._dbType; const content = generateLocalEnvContent(localVars); fs.writeFileSync(localPath, content, 'utf8'); results.local = localPath; results.dbType = dbType; // Switch Prisma schema provider to match chosen database try { switchPrismaProvider(dbType); console.log(green(` Prisma schema switched to ${dbType}`)); } catch (err) { console.log(yellow(` Warning: could not update prisma/schema.prisma: ${err.message}`)); } // Push schema to database console.log(dim(' Pushing database schema...')); let dbReady = false; try { execSync('npx prisma generate', { cwd: path.join(__dirname, '..'), stdio: 'pipe' }); execSync('npx prisma db push --accept-data-loss', { cwd: path.join(__dirname, '..'), stdio: 'pipe' }); console.log(green(' Database schema synced')); dbReady = true; } catch (err) { console.log(yellow(' Warning: could not push schema to database.')); console.log(dim(` ${err.message?.split('\n')[0] || ''}`)); console.log(dim(' Run "npx prisma db push" manually after ensuring the database is running.')); } // Write AI/email/MCP config to SystemConfig DB table // so the admin panel shows correct values instead of hardcoded defaults if (dbReady) { console.log(dim(' Writing settings to database...')); writeConfigToDb(localVars); } // Create admin account const wantAdmin = await askYesNo('\n Create an admin account now?', true); if (wantAdmin) { await createLocalAdmin(); results.adminCreated = true; } // Capture shared secret for docker section if (envChoice === 'both') { sharedSecret = localVars.NEXTAUTH_SECRET; } } else { console.log(yellow(' Skipped .env')); if (envChoice === 'both') { sharedSecret = sharedSecret; // keep generated one } } } // Docker section if (envChoice === 'docker' || envChoice === 'both') { // Verify docker-compose.yml exists in parent const parentDir = path.resolve(__dirname, '..', '..'); const dockerComposePath = path.join(parentDir, 'docker-compose.yml'); if (!fs.existsSync(dockerComposePath)) { console.log(red(`\n Error: docker-compose.yml not found at ${parentDir}`)); console.log(red(' Make sure you are running this from the memento-note directory inside the Memento project.')); process.exit(1); } const dockerVars = await collectDockerEnv(sharedSecret); const dockerEnvPath = path.join(parentDir, '.env.docker'); showRecap(dockerVars, 'Docker .env.docker recap:'); const action = await handleExistingFile(dockerEnvPath); if (action === 'write') { const content = generateDockerEnvContent(dockerVars); fs.writeFileSync(dockerEnvPath, content, 'utf8'); results.docker = dockerEnvPath; } else { console.log(yellow(' Skipped .env.docker')); } } showNextSteps(results); rl.close(); } main().catch((err) => { console.error(red(`\n Unexpected error: ${err.message}`)); rl.close(); process.exit(1); });