|
|
|
|
@@ -0,0 +1,991 @@
|
|
|
|
|
#!/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 Momento/)')}`);
|
|
|
|
|
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', '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 Momento 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);
|
|
|
|
|
});
|