# ============================================================ # Memento - Docker Deploy Script (Windows PowerShell) # ============================================================ # Usage: # .\scripts\deploy-docker.ps1 # Full setup # .\scripts\deploy-docker.ps1 -EnvOnly # Generate .env.docker only # .\scripts\deploy-docker.ps1 -Build # Build + deploy (no env setup) # .\scripts\deploy-docker.ps1 -Stop # Stop all containers # .\scripts\deploy-docker.ps1 -Logs # Show logs # ============================================================ [CmdletBinding()] param( [switch]$EnvOnly = $false, [switch]$Build = $false, [switch]$Full = $false, [switch]$Stop = $false, [switch]$Logs = $false ) $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ProjectDir = Split-Path -Parent $ScriptDir $EnvFile = Join-Path $ProjectDir ".env.docker" # ----------------------------------------------------------- # Helpers # ----------------------------------------------------------- function Write-Info($msg) { Write-Host "[INFO] $msg" -ForegroundColor Blue } function Write-Ok($msg) { Write-Host "[OK] $msg" -ForegroundColor Green } function Write-Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } function Write-Err($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red; exit 1 } function Write-Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } function Get-RandomSecret { $bytes = New-Object byte[] 32 [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) [Convert]::ToBase64String($bytes) } function Get-RandomPassword { $bytes = New-Object byte[] 16 [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) ($bytes | ForEach-Object { $_.ToString("x2") }) -join "" } function Ask-Input { param([string]$Prompt, [string]$Default = "") if ($Default) { Write-Host " ? $Prompt [$Default]: " -ForegroundColor Cyan -NoNewline } else { Write-Host " ? $Prompt: " -ForegroundColor Cyan -NoNewline } $result = Read-Host if ([string]::IsNullOrWhiteSpace($result)) { $result = $Default } return $result } function Ask-Required { param([string]$Prompt) while ($true) { Write-Host " ? $Prompt (required): " -ForegroundColor Cyan -NoNewline $result = Read-Host if (-not [string]::IsNullOrWhiteSpace($result)) { return $result } Write-Host " This field is required." -ForegroundColor Red } } function Ask-Email { param([string]$Prompt) while ($true) { Write-Host " ? $Prompt (required): " -ForegroundColor Cyan -NoNewline $result = Read-Host if (-not [string]::IsNullOrWhiteSpace($result) -and $result -match '^[^@]+@[^@]+\.[^@]+$') { return $result } Write-Host " Please enter a valid email address." -ForegroundColor Red } } # ----------------------------------------------------------- # Check dependencies # ----------------------------------------------------------- function Check-Deps { Write-Step "Checking dependencies..." if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Err "Docker is not installed. Install from: https://docs.docker.com/desktop/install/windows-install/" } try { docker info 2>&1 | Out-Null } catch { Write-Err "Docker daemon is not running. Start Docker Desktop first." } try { docker compose version 2>&1 | Out-Null $script:ComposeCmd = "docker compose" } catch { if (Get-Command docker-compose -ErrorAction SilentlyContinue) { $script:ComposeCmd = "docker-compose" } else { Write-Err "Docker Compose is not installed. Install from: https://docs.docker.com/compose/install/" } } Write-Ok "All dependencies met" } # ----------------------------------------------------------- # Generate .env.docker # ----------------------------------------------------------- function Generate-Env { Write-Step "Configuring Memento for Docker deployment" if (Test-Path $EnvFile) { Write-Warn ".env.docker already exists." $confirm = Ask-Input "Overwrite?" "N" if ($confirm -notmatch "^[Yy]") { Write-Info "Keeping existing .env.docker" return } } Write-Host "" Write-Host " This wizard will guide you through the configuration." -ForegroundColor White Write-Host " Press Enter to accept defaults in [brackets]." -ForegroundColor White Write-Host "" # ---- Core ---- Write-Step "Core configuration" $url = Ask-Input "App URL (NEXTAUTH_URL)" "http://localhost:3000" $secret = Get-RandomSecret Write-Info "Auto-generated NEXTAUTH_SECRET" $adminEmail = Ask-Email "Admin email (first user with this email becomes ADMIN)" $allowReg = Ask-Input "Allow public registration" "true" if ($allowReg -match "^[Yy]|^[Yy]es|^true|^1") { $allowReg = "true" } else { $allowReg = "false" } # ---- PostgreSQL ---- Write-Step "PostgreSQL configuration" $pgPort = Ask-Input "PostgreSQL exposed port" "5433" $pgDb = Ask-Input "PostgreSQL database name" "memento" $pgUser = Ask-Input "PostgreSQL username" "memento" $pgPass = Get-RandomPassword Write-Info "Auto-generated secure PostgreSQL password" # ---- AI Provider ---- Write-Step "AI Provider configuration" Write-Host " Choose your AI provider:" Write-Host " 1) OpenAI" Write-Host " 2) Ollama (local, requires Ollama container)" Write-Host " 3) OpenRouter" Write-Host " 4) Custom OpenAI-compatible (Groq, Together, Mistral, etc.)" Write-Host " 5) Skip AI configuration" Write-Host "" $aiChoice = Ask-Input "Choice" "5" $aiTagsProvider = $aiTagsModel = $aiEmbedProvider = $aiEmbedModel = "" $aiChatProvider = $aiChatModel = $openaiKey = $customKey = $customUrl = $ollamaUrl = "" switch ($aiChoice) { "1" { $aiTagsProvider = "openai"; $aiTagsModel = "gpt-4o-mini" $aiEmbedProvider = "openai"; $aiEmbedModel = "text-embedding-3-small" $aiChatProvider = "openai"; $aiChatModel = "gpt-4o-mini" $openaiKey = Ask-Required "OpenAI API Key" } "2" { $aiTagsProvider = "ollama"; $aiTagsModel = "granite4:latest" $aiEmbedProvider = "ollama"; $aiEmbedModel = "embeddinggemma:latest" $aiChatProvider = "ollama"; $aiChatModel = "granite4:latest" $ollamaUrl = Ask-Input "Ollama base URL" "http://ollama:11434" } "3" { $aiTagsProvider = "custom"; $aiTagsModel = "google/gemma-3-27b-it" $aiEmbedProvider = "custom"; $aiEmbedModel = "text-embedding-3-small" $aiChatProvider = "custom"; $aiChatModel = "google/gemma-3-27b-it" $customUrl = "https://openrouter.ai/api/v1" $customKey = Ask-Required "OpenRouter API Key" $customUrl = Ask-Input "OpenRouter base URL" $customUrl } "4" { $aiTagsProvider = "custom" $aiEmbedProvider = "custom" $aiChatProvider = "custom" $customKey = Ask-Required "Custom provider API Key" $customUrl = Ask-Required "Custom provider base URL" $aiTagsModel = Ask-Input "Model for tags" "gpt-4o-mini" $aiEmbedModel = Ask-Input "Model for embeddings" "text-embedding-3-small" $aiChatModel = Ask-Input "Model for chat" "gpt-4o-mini" } "5" { Write-Info "Skipping AI configuration. You can configure it later in the admin panel." } default { Write-Err "Invalid choice" } } # ---- MCP Server ---- Write-Step "MCP Server configuration" Write-Host " The MCP server allows external tools (Claude, Cline, N8N) to interact with Memento." Write-Host "" $mcpEnable = Ask-Input "Enable MCP server?" "yes" $mcpEnable = ($mcpEnable -match "^[Yy]|^[Yy]es|^true|^1") $mcpPort = "3001"; $mcpServerMode = "sse"; $mcpServerUrl = ""; $mcpApiKey = "" if ($mcpEnable) { $mcpPort = Ask-Input "MCP server port" "3001" $mcpServerMode = Ask-Input "MCP server mode (sse or stdio)" "sse" $mcpServerUrl = "$($url -replace ':\d+$',''):$mcpPort" $mcpAuth = Ask-Input "Require MCP authentication?" "yes" if ($mcpAuth -match "^[Yy]|^[Yy]es|^true|^1") { $mcpApiKey = Get-RandomPassword Write-Info "Auto-generated MCP API key" } } # ---- Email ---- Write-Step "Email configuration (optional, needed for password reset)" Write-Host " Choose an email provider:" Write-Host " 1) Resend" Write-Host " 2) SMTP" Write-Host " 3) Skip" Write-Host "" $emailChoice = Ask-Input "Choice" "3" $resendKey = $smtpHost = $smtpPort = $smtpUser = $smtpPass = $smtpFrom = "" switch ($emailChoice) { "1" { $resendKey = Ask-Required "Resend API Key" } "2" { $smtpHost = Ask-Required "SMTP Host" $smtpPort = Ask-Input "SMTP Port" "587" $smtpUser = Ask-Required "SMTP Username" $smtpPass = Ask-Required "SMTP Password" $smtpFrom = Ask-Required "SMTP From email" } } # ---- Ollama container ---- $enableOllama = "no" if ($aiChoice -eq "2") { $enableOllama = "yes" } else { Write-Step "Ollama container (optional)" $enableOllama = Ask-Input "Also start Ollama container?" "no" $enableOllama = $(if ($enableOllama -match "^[Yy]|^[Yy]es|^true|^1") { "yes" } else { "no" }) } # ---- Web Search ---- Write-Step "Web Search configuration (optional)" Write-Host " Choose a web search provider:" Write-Host " 1) SearXNG (self-hosted)" Write-Host " 2) Brave Search" Write-Host " 3) Skip" Write-Host "" $searchChoice = Ask-Input "Choice" "3" $searxngUrl = $braveKey = "" switch ($searchChoice) { "1" { $searxngUrl = Ask-Required "SearXNG URL" } "2" { $braveKey = Ask-Required "Brave Search API Key" } } # ---- Write .env.docker ---- Write-Step "Writing .env.docker" $envContent = @" # ============================================================================= # Memento - Docker Environment (auto-generated by deploy-docker.ps1) # ============================================================================= # Generated on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') # ============================================================================= # Core NEXTAUTH_URL="$url" NEXTAUTH_SECRET="$secret" ADMIN_EMAIL="$adminEmail" ALLOW_REGISTRATION="$allowReg" # PostgreSQL POSTGRES_PORT=$pgPort POSTGRES_DB=$pgDb POSTGRES_USER=$pgUser POSTGRES_PASSWORD="$pgPass" "@ if ($aiChoice -ne "5") { $envContent += @" # AI - Tags AI_PROVIDER_TAGS=$aiTagsProvider AI_MODEL_TAGS="$aiTagsModel" # AI - Embeddings AI_PROVIDER_EMBEDDING=$aiEmbedProvider AI_MODEL_EMBEDDING="$aiEmbedModel" # AI - Chat AI_PROVIDER_CHAT=$aiChatProvider AI_MODEL_CHAT="$aiChatModel" "@ if ($openaiKey) { $envContent += "`nOPENAI_API_KEY=`"$openaiKey`"" } if ($customKey) { $envContent += "`nCUSTOM_OPENAI_API_KEY=`"$customKey`"`nCUSTOM_OPENAI_BASE_URL=`"$customUrl`"" } if ($ollamaUrl) { $envContent += "`nOLLAMA_BASE_URL=`"$ollamaUrl`"" } } if ($mcpEnable) { $envContent += @" # MCP Server MCP_MODE="$mcpServerMode" MCP_PORT="$mcpPort" MCP_SERVER_MODE="$mcpServerMode" MCP_SERVER_URL="$mcpServerUrl" "@ if ($mcpApiKey) { $envContent += "`nMCP_API_KEY=`"$mcpApiKey`"" } } else { $envContent += @" # MCP Server (disabled) MCP_SERVER_MODE="disabled" "@ } if ($resendKey) { $envContent += "`n`n# Email - Resend`nRESEND_API_KEY=`"$resendKey`"" } if ($smtpHost) { $envContent += @" # Email - SMTP SMTP_HOST="$smtpHost" SMTP_PORT="$smtpPort" SMTP_USER="$smtpUser" SMTP_PASS="$smtpPass" SMTP_FROM="$smtpFrom" "@ } if ($searxngUrl) { $envContent += "`n`n# Web Search - SearXNG`nWEB_SEARCH_PROVIDER=`"searxng`"`nSEARXNG_URL=`"$searxngUrl`"" } if ($braveKey) { $envContent += "`n`n# Web Search - Brave`nWEB_SEARCH_PROVIDER=`"brave`"`nBRAVE_SEARCH_API_KEY=`"$braveKey`"" } $envContent | Set-Content -Path $EnvFile -Encoding UTF8 Write-Ok ".env.docker created at $EnvFile" Write-Host "" Write-Host " Configuration summary:" -ForegroundColor White Write-Host " URL: $url" Write-Host " Admin email: $adminEmail" Write-Host " Registration: $allowReg" Write-Host " PostgreSQL user: $pgUser / db: $pgDb" Write-Host " AI provider: $(if ($aiChoice -eq '5') { 'skipped' } else { $aiTagsProvider })" Write-Host " MCP server: $(if ($mcpEnable) { "enabled ($mcpServerMode)" } else { 'disabled' })" Write-Host " Email: $(if ($emailChoice -eq '3') { 'skipped' } else { 'configured' })" Write-Host " Ollama container: $enableOllama" Write-Host " (sensitive values are hidden)" } # ----------------------------------------------------------- # Build and deploy # ----------------------------------------------------------- function Deploy-Containers { if (-not (Test-Path $EnvFile)) { Write-Err ".env.docker not found. Run: .\scripts\deploy-docker.ps1 -EnvOnly" } Push-Location $ProjectDir # Determine Ollama profile $envContent = Get-Content $EnvFile -Raw $ollamaProfile = "" if ($envContent -match 'OLLAMA_BASE_URL="http://ollama' -or $envContent -match 'AI_PROVIDER_TAGS=ollama') { $ollamaProfile = "--profile ollama" } Write-Step "Building Docker containers..." Invoke-Expression "$ComposeCmd build --parallel" 2>&1 Write-Step "Starting containers..." Invoke-Expression "$ComposeCmd up -d $ollamaProfile" 2>&1 Write-Step "Waiting for services to be healthy..." $retries = 0 $maxRetries = 45 while ($retries -lt $maxRetries) { $status = Invoke-Expression "$ComposeCmd ps --format '{{.Status}}'" 2>$null $unhealthy = ($status | Where-Object { $_ -notmatch "healthy|Up" }).Count if ($unhealthy -eq 0) { break } $retries++ Start-Sleep -Seconds 2 Write-Host -NoNewline "." } Write-Host "" if ($retries -ge $maxRetries) { Write-Warn "Some containers may still be starting. Check status with: .\scripts\deploy-docker.ps1 -Logs" } Write-Step "Waiting for database migrations (handled by entrypoint)..." # The docker-entrypoint.sh runs prisma migrate deploy automatically on every start. # It handles: fresh DB, existing DB with migrations, and P3005 baseline recovery. # No manual migration step needed here. Write-Host "" Write-Host "============================================" -ForegroundColor Green Write-Host " Memento is running!" -ForegroundColor Green Write-Host "============================================" -ForegroundColor Green Write-Host "" Invoke-Expression "$ComposeCmd ps" $appUrl = (Select-String -Path $EnvFile -Pattern '^NEXTAUTH_URL=(.+)$').Matches[0].Groups[1].Value -replace '"','' $admEmail = (Select-String -Path $EnvFile -Pattern '^ADMIN_EMAIL=(.+)$').Matches[0].Groups[1].Value -replace '"','' Write-Host " App: $appUrl" Write-Host " Admin: Register with $admEmail to get admin access" Write-Host "" Write-Host " Useful commands:" -ForegroundColor Cyan Write-Host " .\scripts\deploy-docker.ps1 -Logs View logs" Write-Host " .\scripts\deploy-docker.ps1 -Stop Stop containers" Write-Host " .\scripts\deploy-docker.ps1 -EnvOnly Reconfigure .env.docker" Pop-Location } # ----------------------------------------------------------- # Stop containers # ----------------------------------------------------------- function Stop-Containers { Push-Location $ProjectDir Write-Step "Stopping containers..." Invoke-Expression "$ComposeCmd down" 2>&1 Write-Ok "Containers stopped" Pop-Location } # ----------------------------------------------------------- # Show logs # ----------------------------------------------------------- function Show-Logs { Push-Location $ProjectDir Invoke-Expression "$ComposeCmd logs -f --tail=100" Pop-Location } # ----------------------------------------------------------- # Main # ----------------------------------------------------------- Check-Deps if ($EnvOnly) { Generate-Env } elseif ($Build) { Deploy-Containers } elseif ($Stop) { Stop-Containers } elseif ($Logs) { Show-Logs } else { # Default: full setup Generate-Env Write-Host "" Deploy-Containers }