From bdce6d66f1f539f33a5d6f9ed6b013e57f5ccb15 Mon Sep 17 00:00:00 2001 From: Ugur Akdogan Date: Tue, 30 Jun 2026 20:17:43 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Polish=20self-host=20install/start=20UX=20?= =?UTF-8?q?=E2=80=94=20CLI-style=20wizard,=20idempotent=20boot.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect existing .env and offer start/reconfigure instead of blind overwrite; validate Neo4j password length, shell env compose wrapper, start.sh for daily boot. Co-authored-by: Cursor --- .env.example | 2 +- install.ps1 | 480 ++++++++++++++++++++++++++++-------- install.sh | 266 ++++++++++++++------ package.json | 5 +- scripts/install-ui.sh | 302 +++++++++++++++++++++++ scripts/solarch-compose.ps1 | 7 + scripts/solarch-compose.sh | 5 + scripts/solarch-reset-db.sh | 22 ++ start.ps1 | 53 ++++ start.sh | 62 +++++ 10 files changed, 1018 insertions(+), 186 deletions(-) create mode 100755 scripts/install-ui.sh create mode 100644 scripts/solarch-compose.ps1 create mode 100755 scripts/solarch-compose.sh create mode 100644 scripts/solarch-reset-db.sh create mode 100644 start.ps1 create mode 100755 start.sh diff --git a/.env.example b/.env.example index f50a8f9..b8f78e9 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ BIND_ADDRESS=127.0.0.1 # AI_THROTTLE_LIMIT=20 # CODEGEN_FILL_THROTTLE_LIMIT=10 -# ── Database (Neo4j) ───────────────────────────────────────────────────────── +# ── Database (Neo4j) — minimum 8 characters (Neo4j 5 requirement) ───────────── NEO4J_PASSWORD=change_me_please # ── Local owner identity (no login screen) ─────────────────────────────────── diff --git a/install.ps1 b/install.ps1 index fade998..a760477 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,153 +1,429 @@ # Solarch self-host setup wizard (Windows / PowerShell). -# Asks for an AI provider + API key and a Neo4j password, writes .env, and -# (optionally) starts the stack. No secret is ever echoed back or logged. +# Branded like @solarch/cli — validates inputs, writes .env, optionally starts Docker. # # git clone https://github.com/solarch-dev/solarch.git; cd solarch; ./install.ps1 +param( + [switch]$Yes, + [switch]$Reconfigure, + [switch]$Help +) + $ErrorActionPreference = 'Stop' +$InstallVersion = '0.1.0' Set-Location $PSScriptRoot +function Write-Brand([string]$Text) { Write-Host $Text -ForegroundColor '#FD6A09' } +function Write-Muted([string]$Text) { Write-Host $Text -ForegroundColor DarkGray } +function Write-Ok([string]$Text) { Write-Host " ✓ $Text" -ForegroundColor Green } +function Write-Fail([string]$Text) { Write-Host " ✗ $Text" -ForegroundColor Red } +function Write-Warn([string]$Text) { Write-Host " ! $Text" -ForegroundColor '#FD6A09' } + +function Show-Banner { + $logo = @( + ' 11tttt11', + ' iittttiiiittttii', + 'iitttt11 11ttttii', + 'ff11 iiii 11ff', + 'fftt11ii11tttt11ii11ttff', + 'tt 11fftt ttff11 tt', + 'tt tttttt11tttt tt', + 'tt tt11111111tt tt', + 'tt 11fftt1111ttff11 tt', + 'tttttt11ttffff1111tttttt', + 'ff11 1111 11ff', + 'iitttt11 1111 11ttttii', + ' iitttt1111ttttii', + ' 11ffff11' + ) + foreach ($line in $logo) { Write-Host " $line" -ForegroundColor DarkGray } + Write-Host '' + Write-Host ' ' -NoNewline + Write-Brand 'SOLARCH' + Write-Muted ' · self-host setup · ' + Write-Host "v$InstallVersion" -ForegroundColor White + Write-Muted ' ────────────────────────────────────────────' + Write-Muted ' diagram ⟷ code · rules engine · AI architect' + Write-Host '' +} + +function Show-Usage { + Write-Brand 'solarch install' + Write-Muted ' — self-host setup wizard' + Write-Host '' + Write-Muted ' Usage: ./install.ps1 [-Yes] [-Reconfigure] [-Help]' + Write-Muted ' Already installed? menu: start / reconfigure / exit' +} + function Read-Secret([string]$Prompt) { - $sec = Read-Host " $Prompt" -AsSecureString + $sec = Read-Host " $Prompt" -AsSecureString [System.Net.NetworkCredential]::new('', $sec).Password } + function New-Secret { - # Create().GetBytes() works on both Windows PowerShell 5.1 and PowerShell 7+. $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() - $bytes = New-Object byte[] 32 + $bytes = New-Object byte[] 16 $rng.GetBytes($bytes) ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' } -Write-Host "Solarch - self-host setup" -ForegroundColor White -Write-Host "" +function Test-Neo4jPassword([string]$Pw) { $Pw.Length -ge 8 } + +function Get-Neo4jPassword([string]$Initial) { + $pw = $Initial + if ([string]::IsNullOrWhiteSpace($pw)) { + $pw = New-Secret + Write-Ok 'Generated a strong Neo4j password (32 hex chars).' + return $pw + } + while (-not (Test-Neo4jPassword $pw)) { + Write-Fail "Neo4j requires at least 8 characters (yours: $($pw.Length))." + $pw = Read-Host ' Password (Enter = auto-generate)' + if ([string]::IsNullOrWhiteSpace($pw)) { + $pw = New-Secret + Write-Ok 'Generated a strong Neo4j password.' + break + } + } + return $pw +} + +function Test-ApiKey([string]$Key) { -not [string]::IsNullOrWhiteSpace($Key) } + +function Invoke-SolarchCompose { + Remove-Item Env:SOLARCH_BASIC_AUTH_USER -ErrorAction SilentlyContinue + Remove-Item Env:SOLARCH_BASIC_AUTH_HASH -ErrorAction SilentlyContinue + & docker compose @args +} + +function Test-Preflight { + Write-Host '' + Write-Brand 'Preflight' + Write-Muted ' Checking Docker…' + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Fail 'Docker not found. Install Docker Desktop: https://docs.docker.com/get-docker/' + exit 1 + } + try { docker compose version | Out-Null } catch { + Write-Fail "Docker Compose v2 required (docker compose)." + exit 1 + } + try { docker info 2>&1 | Out-Null } catch { + Write-Fail 'Docker daemon is not running. Start Docker Desktop, then re-run.' + exit 1 + } + Write-Ok 'Docker ready' +} + +function Test-ExistingEnv([string]$Path) { + if (-not (Test-Path $Path)) { return $true } + $line = Get-Content $Path | Where-Object { $_ -match '^NEO4J_PASSWORD=' } | Select-Object -First 1 + if (-not $line) { return $true } + $pw = ($line -split '=', 2)[1] + if ($pw -and -not (Test-Neo4jPassword $pw)) { + Write-Fail ".env NEO4J_PASSWORD has only $($pw.Length) chars (Neo4j needs ≥8)." + return $false + } + return $true +} + +function Test-EnvComplete([string]$Path = '.env') { + if (-not (Test-Path $Path)) { return $false } + if (-not (Test-ExistingEnv $Path)) { return $false } + $lines = Get-Content $Path + if (-not ($lines | Where-Object { $_ -match '^NEO4J_PASSWORD=.+' })) { return $false } + if (-not ($lines | Where-Object { $_ -match '^LLM_GENERATION_PROVIDER=.+' })) { return $false } + if (-not ($lines | Where-Object { $_ -match '^LLM_CHAT_PROVIDER=.+' })) { return $false } + $provider = (($lines | Where-Object { $_ -match '^LLM_GENERATION_PROVIDER=' } | Select-Object -First 1) -split '=', 2)[1] + if ($provider -eq 'ollama') { + return [bool]($lines | Where-Object { $_ -match '^OLLAMA_BASE_URL=.+' }) + } + return [bool]($lines | Where-Object { $_ -match '^(OPENAI|ANTHROPIC|GOOGLE|DEEPSEEK|MISTRAL|GROQ|OPENROUTER|BEDROCK|LLM)_' }) +} + +function Test-Neo4jVolume { + $vols = docker volume ls -q 2>$null + return [bool]($vols | Where-Object { $_ -match 'solarch_neo4j_data$' }) +} + +function Test-StackRunning { + $ids = Invoke-SolarchCompose ps --status running -q web 2>$null + return [bool]$ids +} + +function Get-EnvSummary { + $lines = Get-Content '.env' + $provider = (($lines | Where-Object { $_ -match '^LLM_GENERATION_PROVIDER=' } | Select-Object -First 1) -split '=', 2)[1] + $model = (($lines | Where-Object { $_ -match '^LLM_MODEL=' } | Select-Object -First 1) -split '=', 2)[1] + if ($model) { return "$provider · $model" } + return $provider +} + +function Handle-ExistingInstall([string]$Choice = '') { + Write-Host '' + Write-Ok 'Solarch is already set up on this machine.' + Write-Muted " Config .env ($(Get-EnvSummary))" + if (Test-Neo4jVolume) { Write-Muted ' Database Neo4j volume present (local projects kept)' } + if (Test-StackRunning) { Write-Muted ' Stack running → http://localhost:3000' } + elseif (Invoke-SolarchCompose ps -aq 2>$null) { Write-Muted ' Stack stopped' } + else { Write-Muted ' Stack not created yet' } + Write-Host '' + if (-not $Choice) { + Write-Host @" + 1) Start stack (recommended if stopped) + 2) Reconfigure — new .env wizard (keeps DB unless you reset) + 3) Exit +"@ -ForegroundColor DarkGray + $Choice = Read-Host ' Choice [1-3] (default 1)' + if ([string]::IsNullOrWhiteSpace($Choice)) { $Choice = '1' } + } elseif ($Choice -in @('start','up')) { $Choice = '1' } + elseif ($Choice -in @('reconfigure','configure')) { $Choice = '2' } + elseif ($Choice -in @('exit','quit')) { $Choice = '3' } + switch ($Choice) { + '1' { + if (Test-StackRunning) { + Write-Ok 'Already running at http://localhost:3000' + Write-Muted ' Logs: .\scripts\solarch-compose.ps1 logs -f' + exit 0 + } + Write-Ok 'Starting stack…' + Invoke-SolarchCompose up --build + exit $LASTEXITCODE + } + '2' { + Write-Warn 'Reconfigure will overwrite .env.' + $ans = Read-Host ' Continue? [y/N]' + if ($ans -notmatch '^[yY]') { Write-Ok 'Cancelled.'; exit 0 } + $line = Get-Content .env | Where-Object { $_ -match '^NEO4J_PASSWORD=' } | Select-Object -First 1 + if ($line) { $script:OldNeo4jPw = ($line -split '=', 2)[1] } + return + } + '3' { Write-Ok 'Nothing changed.'; exit 0 } + default { Write-Fail 'Invalid choice.'; exit 1 } + } +} -# Prerequisites -if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { - Write-Error "Docker is required. Install Docker Desktop, then re-run ./install.ps1 (https://docs.docker.com/get-docker/)" - exit 1 +function Show-SummaryBox([string[]]$Lines) { + Write-Host '' + Write-Muted ' +-- Ready --------------------------------------+' + foreach ($l in $Lines) { + Write-Host (' | {0,-42} |' -f $l) -ForegroundColor DarkGray + } + Write-Muted ' +----------------------------------------------+' } -try { docker compose version | Out-Null } catch { - Write-Error "Docker Compose v2 is required (the 'docker compose' command)." - exit 1 + +if ($Help) { Show-Usage; exit 0 } + +Show-Banner +Test-Preflight + +$script:OldNeo4jPw = '' + +if (-not $Reconfigure -and (Test-EnvComplete '.env')) { + if ($Yes) { Handle-ExistingInstall 'start' } + Handle-ExistingInstall } -if (Test-Path .env) { - $ans = Read-Host ".env already exists. Overwrite it? [y/N]" - if ($ans -notmatch '^[yY]') { Write-Host "Keeping existing .env."; exit 0 } +if ((Test-Path .env) -and -not (Test-ExistingEnv .env)) { + Write-Warn '.env exists but is invalid.' + $fix = Read-Host ' Run reconfigure wizard to fix? [Y/n]' + if ($fix -match '^[nN]') { + Write-Muted ' Edit .env manually, then: .\scripts\solarch-compose.ps1 up --build' + exit 1 + } + $line = Get-Content .env | Where-Object { $_ -match '^NEO4J_PASSWORD=' } | Select-Object -First 1 + if ($line) { $script:OldNeo4jPw = ($line -split '=', 2)[1] } +} elseif ($Reconfigure -and (Test-Path .env)) { + $line = Get-Content .env | Where-Object { $_ -match '^NEO4J_PASSWORD=' } | Select-Object -First 1 + if ($line) { $script:OldNeo4jPw = ($line -split '=', 2)[1] } + Write-Warn 'Reconfigure — will overwrite .env.' } -# 1) AI provider -Write-Host "1) Choose your AI provider" -ForegroundColor White -Write-Host " The AI Architect needs a tool-calling-capable model. Bring your own key." -ForegroundColor DarkGray +# Step 1 — AI +Write-Host '' +Write-Brand 'Step 1/3' +Write-Host ' AI provider' -ForegroundColor White +Write-Muted ' Tool-calling model + your API key.' Write-Host @" - 1) OpenAI 6) Groq - 2) Anthropic 7) OpenRouter (300+ models) - 3) Google Gemini 8) Ollama (local, no key) - 4) DeepSeek 9) Bedrock (OpenAI-compatible) - 5) Mistral 10) Custom OpenAI-compatible -"@ -$pick = Read-Host " Provider [1-10] (default 1)" -if ([string]::IsNullOrWhiteSpace($pick)) { $pick = "1" } - -$provider = ""; $keyLines = @(); $model = ""; $modelDefault = ""; $askModel = $true + 1) OpenAI 6) Groq + 2) Anthropic 7) OpenRouter (300+ models) + 3) Google Gemini 8) Ollama (local, no key) + 4) DeepSeek 9) Bedrock (OpenAI-compatible) + 5) Mistral 10) Custom OpenAI-compatible +"@ -ForegroundColor DarkGray + +$pick = Read-Host ' Provider [1-10] (default 1)' +if ([string]::IsNullOrWhiteSpace($pick)) { $pick = '1' } + +$provider = ''; $keyLines = @(); $model = ''; $modelDefault = ''; $askModel = $true +$bedrockUrl = ''; $llmUrl = '' + switch ($pick) { - "1" { $provider="openai"; $keyLines=@("OPENAI_API_KEY=$(Read-Secret 'OPENAI_API_KEY')"); $modelDefault="gpt-4o" } - "2" { $provider="anthropic"; $keyLines=@("ANTHROPIC_API_KEY=$(Read-Secret 'ANTHROPIC_API_KEY')"); $modelDefault="claude-3-5-sonnet-latest" } - "3" { $provider="google"; $keyLines=@("GOOGLE_API_KEY=$(Read-Secret 'GOOGLE_API_KEY')"); $modelDefault="gemini-1.5-pro" } - "4" { $provider="deepseek"; $keyLines=@("DEEPSEEK_API_KEY=$(Read-Secret 'DEEPSEEK_API_KEY')"); $askModel=$false } - "5" { $provider="mistral"; $keyLines=@("MISTRAL_API_KEY=$(Read-Secret 'MISTRAL_API_KEY')"); $modelDefault="mistral-large-latest" } - "6" { $provider="groq"; $keyLines=@("GROQ_API_KEY=$(Read-Secret 'GROQ_API_KEY')"); $modelDefault="llama-3.3-70b-versatile" } - "7" { $provider="openrouter"; $keyLines=@("OPENROUTER_API_KEY=$(Read-Secret 'OPENROUTER_API_KEY')"); $modelDefault="openai/gpt-4o" } - "8" { - $provider="ollama"; $askModel=$false - $ob = Read-Host " OLLAMA_BASE_URL [http://host.docker.internal:11434]" - if ([string]::IsNullOrWhiteSpace($ob)) { $ob = "http://host.docker.internal:11434" } - $om = Read-Host " Model (e.g. llama3.1)"; if ([string]::IsNullOrWhiteSpace($om)) { $om = "llama3.1" } - $keyLines=@("OLLAMA_BASE_URL=$ob"); $model = $om - } - "9" { - $provider="bedrock"; $askModel=$false - $bk = Read-Secret 'BEDROCK_API_KEY' - $bu = Read-Host " BEDROCK_BASE_URL" - $keyLines=@("BEDROCK_API_KEY=$bk","BEDROCK_BASE_URL=$bu") - } - "10" { - $provider="openai-compatible"; $askModel=$false - $lk = Read-Secret 'LLM_API_KEY' - $lu = Read-Host " LLM_BASE_URL" - $lm = Read-Host " Model" - $keyLines=@("LLM_API_KEY=$lk","LLM_BASE_URL=$lu"); $model = $lm - } - default { Write-Error "Invalid choice."; exit 1 } + '1' { $provider='openai'; $k=Read-Secret 'OPENAI_API_KEY'; $keyLines=@("OPENAI_API_KEY=$k"); $modelDefault='gpt-4o' } + '2' { $provider='anthropic'; $k=Read-Secret 'ANTHROPIC_API_KEY'; $keyLines=@("ANTHROPIC_API_KEY=$k"); $modelDefault='claude-3-5-sonnet-latest' } + '3' { $provider='google'; $k=Read-Secret 'GOOGLE_API_KEY'; $keyLines=@("GOOGLE_API_KEY=$k"); $modelDefault='gemini-1.5-pro' } + '4' { $provider='deepseek'; $k=Read-Secret 'DEEPSEEK_API_KEY'; $keyLines=@("DEEPSEEK_API_KEY=$k"); $askModel=$false } + '5' { $provider='mistral'; $k=Read-Secret 'MISTRAL_API_KEY'; $keyLines=@("MISTRAL_API_KEY=$k"); $modelDefault='mistral-large-latest' } + '6' { $provider='groq'; $k=Read-Secret 'GROQ_API_KEY'; $keyLines=@("GROQ_API_KEY=$k"); $modelDefault='llama-3.3-70b-versatile' } + '7' { $provider='openrouter'; $k=Read-Secret 'OPENROUTER_API_KEY'; $keyLines=@("OPENROUTER_API_KEY=$k"); $modelDefault='openai/gpt-4o' } + '8' { + $provider='ollama'; $askModel=$false + $ob = Read-Host ' OLLAMA_BASE_URL [http://host.docker.internal:11434]' + if ([string]::IsNullOrWhiteSpace($ob)) { $ob = 'http://host.docker.internal:11434' } + $om = Read-Host ' Model [llama3.1]'; if ([string]::IsNullOrWhiteSpace($om)) { $om = 'llama3.1' } + $keyLines=@("OLLAMA_BASE_URL=$ob"); $model = $om + } + '9' { + $provider='bedrock'; $askModel=$false + $bk = Read-Secret 'BEDROCK_API_KEY' + $bedrockUrl = Read-Host ' BEDROCK_BASE_URL' + $keyLines=@("BEDROCK_API_KEY=$bk","BEDROCK_BASE_URL=$bedrockUrl") + } + '10' { + $provider='openai-compatible'; $askModel=$false + $lk = Read-Secret 'LLM_API_KEY' + $llmUrl = Read-Host ' LLM_BASE_URL' + $model = Read-Host ' Model' + $keyLines=@("LLM_API_KEY=$lk","LLM_BASE_URL=$llmUrl") + } + default { Write-Fail 'Invalid choice.'; exit 1 } +} + +if ($provider -ne 'ollama') { + $keyVal = ($keyLines[0] -split '=', 2)[1] + while (-not (Test-ApiKey $keyVal)) { + Write-Fail 'API key cannot be empty.' + switch ($provider) { + 'openai' { $k = Read-Secret 'OPENAI_API_KEY'; $keyLines=@("OPENAI_API_KEY=$k") } + 'anthropic' { $k = Read-Secret 'ANTHROPIC_API_KEY'; $keyLines=@("ANTHROPIC_API_KEY=$k") } + 'google' { $k = Read-Secret 'GOOGLE_API_KEY'; $keyLines=@("GOOGLE_API_KEY=$k") } + 'deepseek' { $k = Read-Secret 'DEEPSEEK_API_KEY'; $keyLines=@("DEEPSEEK_API_KEY=$k") } + 'mistral' { $k = Read-Secret 'MISTRAL_API_KEY'; $keyLines=@("MISTRAL_API_KEY=$k") } + 'groq' { $k = Read-Secret 'GROQ_API_KEY'; $keyLines=@("GROQ_API_KEY=$k") } + 'openrouter' { $k = Read-Secret 'OPENROUTER_API_KEY'; $keyLines=@("OPENROUTER_API_KEY=$k") } + 'bedrock' { $k = Read-Secret 'BEDROCK_API_KEY'; $keyLines=@("BEDROCK_API_KEY=$k","BEDROCK_BASE_URL=$bedrockUrl") } + default { $k = Read-Secret 'LLM_API_KEY'; $keyLines=@("LLM_API_KEY=$k","LLM_BASE_URL=$llmUrl") } + } + $keyVal = ($keyLines[0] -split '=', 2)[1] + } + Write-Ok "Provider: $provider" } if ($askModel) { - $m = Read-Host " Model [$modelDefault]" + $m = Read-Host " Model [$modelDefault]" $model = if ([string]::IsNullOrWhiteSpace($m)) { $modelDefault } else { $m } } -# 2) Neo4j password -Write-Host ""; Write-Host "2) Database password (Neo4j)" -ForegroundColor White -$neo = Read-Host " Press Enter to auto-generate, or type a password" -if ([string]::IsNullOrWhiteSpace($neo)) { $neo = New-Secret; Write-Host " Generated a strong password." } +# Step 2 — Neo4j +Write-Host '' +Write-Brand 'Step 2/3' +Write-Host ' Database' -ForegroundColor White +if ((Test-Neo4jVolume) -and $script:OldNeo4jPw) { + Write-Warn 'Neo4j volume exists — keep the same password or the server will not connect.' + $keep = Read-Host ' Keep existing DB password? [Y/n]' + if ($keep -match '^[nN]') { + $neoIn = Read-Host ' New password (Enter = auto-generate)' + $neo = Get-Neo4jPassword $neoIn + } else { + $neo = $script:OldNeo4jPw + Write-Ok 'Keeping existing Neo4j password.' + } +} else { + Write-Muted ' Enter = auto-generate strong password (recommended).' + $neoIn = Read-Host ' Password (Enter = auto-generate)' + $neo = Get-Neo4jPassword $neoIn +} -# 3) Network exposure -Write-Host ""; Write-Host "3) Network exposure" -ForegroundColor White -Write-Host " Local-only is safest. LAN/VPS enables HTTP Basic Auth at the edge." -ForegroundColor DarkGray +# Step 3 — Network +Write-Host '' +Write-Brand 'Step 3/3' +Write-Host ' Network' -ForegroundColor White +Write-Muted ' Local-only is safest. Remote adds HTTP Basic Auth.' Write-Host @" - 1) Local only (127.0.0.1) - default, this machine only - 2) LAN / remote (0.0.0.0 + HTTP Basic Auth) -"@ -$exposure = Read-Host " Exposure [1-2] (default 1)" -if ([string]::IsNullOrWhiteSpace($exposure)) { $exposure = "1" } - -$bindAddress = "127.0.0.1" -$authUser = "" -$authHash = "" -$authPassword = "" - -if ($exposure -eq "2") { - $bindAddress = "0.0.0.0" - $authUser = "solarch" + 1) Local only (127.0.0.1) - this machine only + 2) LAN / remote (0.0.0.0 + HTTP Basic Auth) +"@ -ForegroundColor DarkGray + +$exposure = Read-Host ' Exposure [1-2] (default 1)' +if ([string]::IsNullOrWhiteSpace($exposure)) { $exposure = '1' } + +$bindAddress = '127.0.0.1' +$authUser = '' +$authHash = '' + +if ($exposure -eq '2') { + $bindAddress = '0.0.0.0' + $authUser = 'solarch' $authPassword = (New-Secret).Substring(0, 24) - Write-Host " Generating HTTP Basic Auth credentials..." + Write-Muted ' Generating HTTP Basic Auth…' $authHash = (docker run --rm caddy:2-alpine caddy hash-password --plaintext $authPassword).Trim() - Write-Host "" - Write-Host " Save these credentials - shown once:" -ForegroundColor White - Write-Host " User: $authUser" - Write-Host " Password: $authPassword" - Write-Host "" -} elseif ($exposure -ne "1") { - Write-Error "Invalid choice."; exit 1 + Write-Host '' + Write-Host ' Save these credentials — shown once:' -ForegroundColor White + Write-Host " User: $authUser" + Write-Host " Password: $authPassword" + Write-Host '' + Write-Ok 'Remote exposure + Basic Auth enabled.' +} elseif ($exposure -ne '1') { + Write-Fail 'Invalid choice.'; exit 1 +} else { + Write-Ok 'Local only (127.0.0.1:3000).' } -# Write .env (fresh, real values; never printed) $lines = @( - "# Generated by install.ps1 - do not commit (this file is gitignored).", - "PUBLIC_URL=http://localhost:3000", - "PORT_PUBLIC=3000", + '# Generated by install.ps1 — do not commit (gitignored).', + 'PUBLIC_URL=http://localhost:3000', + 'PORT_PUBLIC=3000', "BIND_ADDRESS=$bindAddress", "NEO4J_PASSWORD=$neo", - "LOCAL_USER_ID=local_owner", + 'LOCAL_USER_ID=local_owner', "LLM_GENERATION_PROVIDER=$provider", "LLM_CHAT_PROVIDER=$provider" ) + $keyLines + if (-not [string]::IsNullOrWhiteSpace($model)) { $lines += "LLM_MODEL=$model" } if ($authUser) { $lines += "SOLARCH_BASIC_AUTH_USER=$authUser" $lines += "SOLARCH_BASIC_AUTH_HASH=$authHash" +} else { + $lines += '# SOLARCH_BASIC_AUTH_USER=' + $lines += '# SOLARCH_BASIC_AUTH_HASH=' } -# Write without a BOM (works on 5.1 and 7+) so docker compose parses the .env cleanly. + $content = ($lines -join "`n") + "`n" [System.IO.File]::WriteAllText((Join-Path (Get-Location) '.env'), $content, (New-Object System.Text.UTF8Encoding($false))) -Write-Host "" -Write-Host "OK - wrote .env (provider: $provider)" -ForegroundColor Green -Write-Host " Secrets were written to .env only - never printed here." -ForegroundColor DarkGray -Write-Host "" -$go = Read-Host "Start Solarch now with 'docker compose up --build'? [Y/n]" -if ($go -match '^[nN]') { - Write-Host "When ready: docker compose up --build -> http://localhost:3000" +Write-Host '' +Write-Ok ".env written (provider: $provider)" +Write-Muted ' Secrets stay in .env only — never printed here.' + +if ($script:OldNeo4jPw -and $neo -ne $script:OldNeo4jPw -and (Test-Neo4jVolume)) { + Write-Warn 'NEO4J_PASSWORD changed — Neo4j volume still has the old password.' + $reset = Read-Host ' Reset database volume (local projects lost)? [Y/n]' + if ($reset -notmatch '^[nN]') { + Write-Ok 'Clearing Neo4j volume…' + Invoke-SolarchCompose down -v 2>$null + Write-Ok 'Neo4j volume cleared.' + } else { + Write-Warn 'Keeping volume — expect auth errors. Fix: .\scripts\solarch-reset-db.ps1' + } +} + +Show-SummaryBox @( + 'Open http://localhost:3000', + "AI $provider$(if ($model) { " · $model" })", + 'Auth no login (local owner)', + 'Stop Ctrl+C · .\scripts\solarch-compose.ps1 down' +) + +Write-Host '' +if ($Yes) { + Write-Ok 'Starting stack…' + Invoke-SolarchCompose up --build } else { - docker compose up --build + $go = Read-Host 'Start Solarch now? [Y/n]' + if ($go -match '^[nN]') { + Write-Muted ' When ready: .\scripts\solarch-compose.ps1 up --build' + } else { + Write-Ok 'Starting stack…' + Invoke-SolarchCompose up --build + } } diff --git a/install.sh b/install.sh index 8ba2dbb..720dc5c 100755 --- a/install.sh +++ b/install.sh @@ -1,62 +1,94 @@ #!/usr/bin/env bash # Solarch self-host setup wizard (Linux / macOS). -# Asks for an AI provider + API key and a Neo4j password, writes .env, and -# (optionally) starts the stack. No secret is ever echoed back or logged. +# Branded like @solarch/cli — validates inputs, writes .env, optionally starts Docker. # # git clone https://github.com/solarch-dev/solarch.git && cd solarch && ./install.sh +# +# Options: +# -y, --yes Start stack (if already set up) or finish wizard then start +# --reconfigure Force the setup wizard even when .env exists +# -h, --help Show help set -euo pipefail -cd "$(dirname "$0")" +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +INSTALL_ROOT="$ROOT" +# shellcheck source=scripts/install-ui.sh +source "$ROOT/scripts/install-ui.sh" -bold() { printf '\033[1m%s\033[0m\n' "$1"; } -dim() { printf '\033[2m%s\033[0m\n' "$1"; } -err() { printf '\033[31m%s\033[0m\n' "$1" >&2; } +INSTALL_VERSION="0.1.0" +AUTO_START=0 +FORCE_RECONFIGURE=0 -bold "Solarch — self-host setup" -echo +usage() { + cat </dev/null 2>&1; then - err "Docker is required but was not found. Install Docker, then re-run ./install.sh" - err " → https://docs.docker.com/get-docker/" - exit 1 -fi -if ! docker compose version >/dev/null 2>&1; then - err "Docker Compose v2 is required (the 'docker compose' command)." - exit 1 + $(muted "Usage:") ./install.sh [options] + + $(muted "Options:") + -y, --yes If already set up: start stack. Otherwise: start after wizard. + --reconfigure Run the full wizard (overwrites .env) + -h, --help Show this help + + $(muted "Already installed?") ./install.sh → menu (start / reconfigure / exit) + $(muted "Day-to-day:") ./scripts/solarch-compose.sh up --build +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + -y|--yes) AUTO_START=1; shift ;; + --reconfigure) FORCE_RECONFIGURE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) ui_fail "Unknown option: $1"; usage >&2; exit 1 ;; + esac +done + +render_install_banner "$INSTALL_VERSION" + +if ! preflight_docker; then exit 1; fi + +OLD_NEO4J_PW="" +ENV_WROTE=0 + +# ── Already installed? ──────────────────────────────────────────────────────── +if [ "$FORCE_RECONFIGURE" = "0" ] && env_is_complete .env; then + if [ "$AUTO_START" = "1" ]; then + handle_existing_install start + fi + handle_existing_install "" fi -# ── Don't clobber an existing .env without asking ───────────────────────────── -if [ -f .env ]; then - read -r -p ".env already exists. Overwrite it? [y/N] " ans - case "${ans:-N}" in - y|Y) : ;; - *) echo "Keeping existing .env. Edit it by hand or remove it and re-run."; exit 0 ;; +# Broken .env — offer fix path without silent overwrite +if [ -f .env ] && ! validate_existing_env .env; then + echo + ui_warn ".env exists but is invalid." + read -r -p "$(muted ' Run reconfigure wizard to fix? [Y/n] ')" ans + case "${ans:-Y}" in + n|N) muted " Edit .env manually, then: ./scripts/solarch-compose.sh up --build"; exit 1 ;; + *) OLD_NEO4J_PW=$(grep -E '^NEO4J_PASSWORD=' .env | head -1 | cut -d= -f2- || true) ;; esac +elif [ -f .env ] && [ "$FORCE_RECONFIGURE" = "1" ]; then + OLD_NEO4J_PW=$(grep -E '^NEO4J_PASSWORD=' .env | head -1 | cut -d= -f2- || true) + ui_warn "Reconfigure — will overwrite .env." fi -gen_secret() { - if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 - else LC_ALL=C tr -dc 'a-f0-9' &2; printf '%s' "$v" -} case "$pick" in 1) PROVIDER=openai; k=$(read_secret "OPENAI_API_KEY"); KEY_LINES="OPENAI_API_KEY=$k"; MODEL_DEFAULT="gpt-4o" ;; @@ -66,43 +98,89 @@ case "$pick" in 5) PROVIDER=mistral; k=$(read_secret "MISTRAL_API_KEY"); KEY_LINES="MISTRAL_API_KEY=$k"; MODEL_DEFAULT="mistral-large-latest" ;; 6) PROVIDER=groq; k=$(read_secret "GROQ_API_KEY"); KEY_LINES="GROQ_API_KEY=$k"; MODEL_DEFAULT="llama-3.3-70b-versatile" ;; 7) PROVIDER=openrouter; k=$(read_secret "OPENROUTER_API_KEY"); KEY_LINES="OPENROUTER_API_KEY=$k"; MODEL_DEFAULT="openai/gpt-4o" ;; - 8) PROVIDER=ollama - read -r -p " OLLAMA_BASE_URL [http://host.docker.internal:11434]: " ob - ob="${ob:-http://host.docker.internal:11434}" - read -r -p " Model (e.g. llama3.1): " om; om="${om:-llama3.1}" - KEY_LINES="OLLAMA_BASE_URL=$ob"; MODEL_DEFAULT="$om"; ASK_MODEL=0 ;; - 9) PROVIDER=bedrock - k=$(read_secret "BEDROCK_API_KEY") - read -r -p " BEDROCK_BASE_URL: " bu - KEY_LINES=$(printf 'BEDROCK_API_KEY=%s\nBEDROCK_BASE_URL=%s' "$k" "$bu"); ASK_MODEL=0 ;; - 10) PROVIDER=openai-compatible - k=$(read_secret "LLM_API_KEY") - read -r -p " LLM_BASE_URL: " lu - read -r -p " Model: " lm - KEY_LINES=$(printf 'LLM_API_KEY=%s\nLLM_BASE_URL=%s' "$k" "$lu"); MODEL_DEFAULT="$lm"; ASK_MODEL=0 ;; - *) err "Invalid choice."; exit 1 ;; + 8) + PROVIDER=ollama; ASK_MODEL=0 + read -r -p "$(muted ' OLLAMA_BASE_URL [http://host.docker.internal:11434]: ')" ob + ob="${ob:-http://host.docker.internal:11434}" + read -r -p "$(muted ' Model [llama3.1]: ')" om; om="${om:-llama3.1}" + KEY_LINES="OLLAMA_BASE_URL=$ob"; MODEL_DEFAULT="$om" + ;; + 9) + PROVIDER=bedrock; ASK_MODEL=0 + k=$(read_secret "BEDROCK_API_KEY") + read -r -p "$(muted ' BEDROCK_BASE_URL: ')" bu + KEY_LINES=$(printf 'BEDROCK_API_KEY=%s\nBEDROCK_BASE_URL=%s' "$k" "$bu") + ;; + 10) + PROVIDER=openai-compatible; ASK_MODEL=0 + k=$(read_secret "LLM_API_KEY") + read -r -p "$(muted ' LLM_BASE_URL: ')" lu + read -r -p "$(muted ' Model: ')" lm + KEY_LINES=$(printf 'LLM_API_KEY=%s\nLLM_BASE_URL=%s' "$k" "$lu"); MODEL_DEFAULT="$lm" + ;; + *) ui_fail "Invalid choice."; exit 1 ;; esac +if [ "$PROVIDER" != "ollama" ]; then + key_val="${KEY_LINES#*=}" + key_val="${key_val%%$'\n'*}" + while ! api_key_ok "$key_val"; do + ui_fail "API key cannot be empty." + case "$PROVIDER" in + openai) k=$(read_secret "OPENAI_API_KEY"); KEY_LINES="OPENAI_API_KEY=$k" ;; + anthropic) k=$(read_secret "ANTHROPIC_API_KEY"); KEY_LINES="ANTHROPIC_API_KEY=$k" ;; + google) k=$(read_secret "GOOGLE_API_KEY"); KEY_LINES="GOOGLE_API_KEY=$k" ;; + deepseek) k=$(read_secret "DEEPSEEK_API_KEY"); KEY_LINES="DEEPSEEK_API_KEY=$k" ;; + mistral) k=$(read_secret "MISTRAL_API_KEY"); KEY_LINES="MISTRAL_API_KEY=$k" ;; + groq) k=$(read_secret "GROQ_API_KEY"); KEY_LINES="GROQ_API_KEY=$k" ;; + openrouter) k=$(read_secret "OPENROUTER_API_KEY"); KEY_LINES="OPENROUTER_API_KEY=$k" ;; + bedrock) k=$(read_secret "BEDROCK_API_KEY"); KEY_LINES=$(printf 'BEDROCK_API_KEY=%s\nBEDROCK_BASE_URL=%s' "$k" "$bu") ;; + openai-compatible) k=$(read_secret "LLM_API_KEY"); KEY_LINES=$(printf 'LLM_API_KEY=%s\nLLM_BASE_URL=%s' "$k" "$lu") ;; + esac + key_val="${KEY_LINES#*=}" + key_val="${key_val%%$'\n'*}" + done + ui_ok "Provider: $(brand "$PROVIDER")" +fi + MODEL="" if [ "$ASK_MODEL" = "1" ]; then - read -r -p " Model [$MODEL_DEFAULT]: " MODEL; MODEL="${MODEL:-$MODEL_DEFAULT}" + read -r -p "$(muted " Model [$MODEL_DEFAULT]: ")" MODEL + MODEL="${MODEL:-$MODEL_DEFAULT}" elif [ "$PROVIDER" != "deepseek" ]; then MODEL="$MODEL_DEFAULT" fi -# ── Neo4j password ──────────────────────────────────────────────────────────── -echo; bold "2) Database password (Neo4j)" -read -r -p " Press Enter to auto-generate, or type a password: " NEO4J_PW -if [ -z "$NEO4J_PW" ]; then NEO4J_PW=$(gen_secret); echo " Generated a strong password."; fi - -# ── Network exposure ────────────────────────────────────────────────────────── -echo; bold "3) Network exposure" -dim " Local-only is safest. LAN/VPS enables HTTP Basic Auth at the edge." -cat <<'MENU' - 1) Local only (127.0.0.1) — default, this machine only - 2) LAN / remote (0.0.0.0 + HTTP Basic Auth) +# ── Step 2: Neo4j ───────────────────────────────────────────────────────────── +ui_step 2 3 "Database" "Neo4j password — Enter auto-generates a strong one (recommended)." + +if neo4j_volume_exists && [ -n "$OLD_NEO4J_PW" ]; then + ui_warn "Neo4j volume exists — keep the same password or the server won't connect." + read -r -p "$(muted ' Keep existing DB password? [Y/n] ')" keep + case "${keep:-Y}" in + n|N) + read -r -p "$(muted ' New password (Enter = auto-generate): ')" NEO4J_PW + NEO4J_PW=$(ensure_neo4j_password "$NEO4J_PW") + ;; + *) + NEO4J_PW="$OLD_NEO4J_PW" + ui_ok "Keeping existing Neo4j password." + ;; + esac +else + read -r -p "$(muted ' Password (Enter = auto-generate): ')" NEO4J_PW + NEO4J_PW=$(ensure_neo4j_password "$NEO4J_PW") +fi + +# ── Step 3: Exposure ────────────────────────────────────────────────────────── +ui_step 3 3 "Network" "Local-only is safest. Remote exposure adds HTTP Basic Auth." + +cat <&2; } +ui_warn() { printf ' %s!%s %s\n' "$_BRAND" "$_RESET" "$1"; } + +render_install_banner() { + local ver="${1:-0.1.0}" + # Logo from @solarch/cli logo.generated.ts (density ramp → orange gradient when TTY) + local lines=( + " 11tttt11" + " iittttiiiittttii" + "iitttt11 11ttttii" + "ff11 iiii 11ff" + "fftt11ii11tttt11ii11ttff" + "tt 11fftt ttff11 tt" + "tt tttttt11tttt tt" + "tt tt11111111tt tt" + "tt 11fftt1111ttff11 tt" + "tttttt11ttffff1111tttttt" + "ff11 1111 11ff" + "iitttt11 1111 11ttttii" + " iitttt1111ttttii" + " 11ffff11" + ) + if _ui_colors; then + local ramp=' .,:;i1tfLCG08@' + for line in "${lines[@]}"; do + local out="" ch idx t + for (( i=0; i<${#line}; i++ )); do + ch="${line:i:1}" + idx="${ramp%%"$ch"*}" + idx="${#idx}" + if [ "$idx" -le 0 ]; then out+="$ch"; continue; fi + t=$(( idx * 100 / (${#ramp} - 1) )) + if [ "$t" -ge 72 ]; then out+="${_BRAND_DEEP}${ch}${_RESET}" + elif [ "$t" -ge 38 ]; then out+="${_BRAND}${ch}${_RESET}" + else out+="${_MUTED}${ch}${_RESET}"; fi + done + printf ' %b\n' "$out" + done + else + for line in "${lines[@]}"; do printf ' %s\n' "$line"; done + fi + echo + printf ' %b %s %b %s %b\n' "$(brand "SOLARCH")" "$(muted "·")" "$(muted "self-host setup")" "$(muted "·")" "$(bold "v${ver}")" + printf ' %s\n' "$(muted "----------------------------------------")" + printf ' %s\n' "$(muted "diagram ⟷ code · rules engine · AI architect")" + echo +} + +ui_step() { + local n="$1" total="$2" title="$3" hint="${4:-}" + printf '\n%s %s\n' "$(brand "Step ${n}/${total}")" "$(bold "$title")" + [ -n "$hint" ] && printf '%s\n' "$(muted " $hint")" +} + +ui_summary_box() { + # ui_summary_box "line1" "line2" ... + local w=44 line + printf '\n %s+-- Ready %s+%s\n' "$_MUTED" "$(printf '%*s' $((w-8)) '' | tr ' ' '-')" "$_RESET" + for line in "$@"; do + printf ' %s|%s %-*s %s|%s\n' "$_MUTED" "$_RESET" "$w" "$line" "$_MUTED" "$_RESET" + done + printf ' %s+%s+%s\n' "$_MUTED" "$(printf '%*s' $((w+2)) '' | tr ' ' '-')" "$_RESET" +} + +gen_secret() { + if command -v openssl >/dev/null 2>&1; then openssl rand -hex 16 + else LC_ALL=C tr -dc 'a-f0-9' &2 + printf '%s' "$v" +} + +docker_compose_clean() { + # Shell-exported SOLARCH_BASIC_AUTH_* overrides .env — strip before compose. + env -u SOLARCH_BASIC_AUTH_USER -u SOLARCH_BASIC_AUTH_HASH docker compose "$@" +} + +preflight_docker() { + printf '\n%s\n' "$(brand "Preflight")" + printf '%s\n' "$(muted " Checking Docker…")" + if ! command -v docker >/dev/null 2>&1; then + ui_fail "Docker not found." + echo "$(muted ' Install: https://docs.docker.com/get-docker/')" >&2 + return 1 + fi + if ! docker compose version >/dev/null 2>&1; then + ui_fail "Docker Compose v2 required (docker compose)." + return 1 + fi + if ! docker info >/dev/null 2>&1; then + ui_fail "Docker daemon is not running. Start Docker, then re-run ./install.sh" + return 1 + fi + ui_ok "Docker $(docker compose version --short 2>/dev/null || echo 'ready')" + return 0 +} + +validate_existing_env() { + local env_file="$1" + [ -f "$env_file" ] || return 0 + local pw + pw=$(grep -E '^NEO4J_PASSWORD=' "$env_file" | head -1 | cut -d= -f2- || true) + if [ -n "$pw" ] && ! neo4j_password_ok "$pw"; then + ui_fail ".env has NEO4J_PASSWORD with only ${#pw} characters (Neo4j needs ≥8)." + ui_warn "Choose reconfigure in the menu, or edit .env by hand." + return 1 + fi + return 0 +} + +# .env exists and has the minimum required keys for a working stack. +env_is_complete() { + local f="${1:-.env}" + [ -f "$f" ] || return 1 + validate_existing_env "$f" || return 1 + grep -qE '^NEO4J_PASSWORD=.+' "$f" || return 1 + grep -qE '^LLM_GENERATION_PROVIDER=.+' "$f" || return 1 + grep -qE '^LLM_CHAT_PROVIDER=.+' "$f" || return 1 + local provider + provider=$(grep -E '^LLM_GENERATION_PROVIDER=' "$f" | head -1 | cut -d= -f2-) + if [ "$provider" = "ollama" ]; then + grep -qE '^OLLAMA_BASE_URL=.+' "$f" || return 1 + else + grep -qE '^(OPENAI|ANTHROPIC|GOOGLE|DEEPSEEK|MISTRAL|GROQ|OPENROUTER|BEDROCK|LLM)_' "$f" || return 1 + fi + return 0 +} + +install_stack_running() { + docker_compose_clean ps --status running -q web 2>/dev/null | grep -q . +} + +install_stack_exists() { + docker_compose_clean ps -aq 2>/dev/null | grep -q . +} + +env_summary_line() { + local f="${1:-.env}" + local provider model + provider=$(grep -E '^LLM_GENERATION_PROVIDER=' "$f" | head -1 | cut -d= -f2- || echo "?") + model=$(grep -E '^LLM_MODEL=' "$f" | head -1 | cut -d= -f2- || true) + if [ -n "$model" ]; then printf '%s · %s' "$provider" "$model" + else printf '%s' "$provider"; fi +} + +# Already installed — don't rerun the wizard unless the user asks. +handle_existing_install() { + local choice="${1:-}" + echo + ui_ok "Solarch is already set up on this machine." + printf '%s\n' "$(muted " Config .env ($(env_summary_line))")" + if neo4j_volume_exists; then + printf '%s\n' "$(muted " Database Neo4j volume present (local projects kept)")" + fi + if install_stack_running; then + printf '%s\n' "$(muted " Stack running → http://localhost:3000")" + elif install_stack_exists; then + printf '%s\n' "$(muted " Stack stopped")" + else + printf '%s\n' "$(muted " Stack not created yet")" + fi + echo + if [ -n "$choice" ]; then + case "$choice" in + start|up) choice=1 ;; + reconfigure|configure|reset) choice=2 ;; + exit|quit) choice=3 ;; + esac + else + cat </dev/null || env -u SOLARCH_BASIC_AUTH_USER -u SOLARCH_BASIC_AUTH_HASH docker compose down -v 2>/dev/null || true + ui_ok "Neo4j volume cleared — fresh database on next start." + ;; + esac +} diff --git a/scripts/solarch-compose.ps1 b/scripts/solarch-compose.ps1 new file mode 100644 index 0000000..7fdfdd4 --- /dev/null +++ b/scripts/solarch-compose.ps1 @@ -0,0 +1,7 @@ +# docker compose wrapper — ignores shell SOLARCH_BASIC_AUTH_* that override .env. +param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Args) +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +Remove-Item Env:SOLARCH_BASIC_AUTH_USER -ErrorAction SilentlyContinue +Remove-Item Env:SOLARCH_BASIC_AUTH_HASH -ErrorAction SilentlyContinue +& docker compose @Args diff --git a/scripts/solarch-compose.sh b/scripts/solarch-compose.sh new file mode 100755 index 0000000..bf2da04 --- /dev/null +++ b/scripts/solarch-compose.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# docker compose wrapper — ignores shell-exported SOLARCH_BASIC_AUTH_* that override .env. +set -euo pipefail +cd "$(dirname "$0")/.." +exec env -u SOLARCH_BASIC_AUTH_USER -u SOLARCH_BASIC_AUTH_HASH docker compose "$@" diff --git a/scripts/solarch-reset-db.sh b/scripts/solarch-reset-db.sh new file mode 100644 index 0000000..83fbce1 --- /dev/null +++ b/scripts/solarch-reset-db.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Reset local Neo4j data (password mismatch / fresh start). Deletes all local projects. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +# shellcheck source=scripts/install-ui.sh +source "$ROOT/scripts/install-ui.sh" + +render_install_banner "reset" +echo +ui_warn "This wipes the Neo4j Docker volume (all local Solarch projects)." +ui_warn "Use when NEO4J_PASSWORD changed after the first docker compose up." +echo +read -r -p "$(muted 'Continue? [y/N] ')" ans +case "${ans:-N}" in + y|Y) ;; + *) muted "Cancelled."; exit 0 ;; +esac + +ui_ok "Stopping stack and removing Neo4j volume…" +docker_compose_clean down -v 2>/dev/null || env -u SOLARCH_BASIC_AUTH_USER -u SOLARCH_BASIC_AUTH_HASH docker compose down -v +ui_ok "Done. Start again: ./scripts/solarch-compose.sh up --build" diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..056cdaf --- /dev/null +++ b/start.ps1 @@ -0,0 +1,53 @@ +# Start Solarch (Docker). First time? Run ./install.ps1 instead. +param( + [switch]$Detach, + [switch]$Build, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +function Write-Brand([string]$Text) { Write-Host $Text -ForegroundColor '#FD6A09' } +function Write-Muted([string]$Text) { Write-Host $Text -ForegroundColor DarkGray } +function Write-Ok([string]$Text) { Write-Host " ✓ $Text" -ForegroundColor Green } +function Write-Fail([string]$Text) { Write-Host " ✗ $Text" -ForegroundColor Red } + +function Test-EnvComplete([string]$Path = '.env') { + if (-not (Test-Path $Path)) { return $false } + $lines = Get-Content $Path + if (-not ($lines | Where-Object { $_ -match '^NEO4J_PASSWORD=.+' })) { return $false } + if (-not ($lines | Where-Object { $_ -match '^LLM_GENERATION_PROVIDER=.+' })) { return $false } + return $true +} + +function Invoke-SolarchCompose { + Remove-Item Env:SOLARCH_BASIC_AUTH_USER -ErrorAction SilentlyContinue + Remove-Item Env:SOLARCH_BASIC_AUTH_HASH -ErrorAction SilentlyContinue + & docker compose @args +} + +if ($Help) { + Write-Brand 'solarch start' + Write-Muted ' Usage: ./start.ps1 [-Detach] [-Build]' + Write-Muted ' First time: ./install.ps1' + exit 0 +} + +if (-not (Test-EnvComplete)) { + Write-Fail 'Run ./install.ps1 first (missing or incomplete .env).' + exit 1 +} + +$running = Invoke-SolarchCompose ps --status running -q web 2>$null +if ($running) { + Write-Ok 'Already running → http://localhost:3000' + exit 0 +} + +$args = @('up') +if ($Build) { $args += '--build' } +if ($Detach) { $args += '-d' } + +Write-Ok 'Starting Solarch…' +Invoke-SolarchCompose @args diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..4820102 --- /dev/null +++ b/start.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Start Solarch (Docker). First time? Run ./install.sh instead. +# +# ./start.sh # foreground (logs in terminal) +# ./start.sh -d # background +# ./start.sh --build # rebuild images then start +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" +INSTALL_ROOT="$ROOT" +# shellcheck source=scripts/install-ui.sh +source "$ROOT/scripts/install-ui.sh" + +DETACH="" +EXTRA=() + +usage() { + cat <&2; exit 1 ;; + esac +done + +if ! env_is_complete .env 2>/dev/null; then + if [ ! -f .env ]; then + ui_fail "No .env yet — run ./install.sh first." + else + ui_fail ".env is incomplete or invalid — run ./install.sh" + fi + exit 1 +fi + +if ! preflight_docker; then exit 1; fi + +if install_stack_running; then + ui_ok "Already running → http://localhost:3000" + muted " Logs: ./scripts/solarch-compose.sh logs -f" + exit 0 +fi + +ui_ok "Starting Solarch…" +exec "${INSTALL_ROOT}/scripts/solarch-compose.sh" up "${EXTRA[@]}" $DETACH From 9d50c1ca7c543dc8db17208dbcb2dd099cc80a67 Mon Sep 17 00:00:00 2001 From: Ugur Akdogan Date: Tue, 30 Jun 2026 20:28:58 +0300 Subject: [PATCH 2/2] Fix codegen e2e skippedKinds test to match current emitter behavior. Cache is now a supported emitter; assert EnvironmentVariable round-trip instead. Co-authored-by: Cursor --- apps/server/test/codegen.e2e-spec.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/server/test/codegen.e2e-spec.ts b/apps/server/test/codegen.e2e-spec.ts index 35f2e6c..9ae36ba 100644 --- a/apps/server/test/codegen.e2e-spec.ts +++ b/apps/server/test/codegen.e2e-spec.ts @@ -241,21 +241,22 @@ it("no project -> 404 ERR_PROJECT_NOT_FOUND", async () => { expect(res.body.error.code).toBe("ERR_PROJECT_NOT_FOUND"); }); - it("skippedKinds + stub: unsupported kind (Cache) survives DB round-trip", async () => { + it("skippedKinds: EnvironmentVariable survives DB round-trip (config, not a code module)", async () => { await seedGraph(); -// Add an unsupported kind (Cache) — emitter produces stub, skippedKinds counts. + // EnvironmentVariable is not in EMITTER_REGISTRY — counted in skippedKinds, no stub file. + // Config is represented once in scaffold .env.example (see codegen.service.spec.ts). await createNode({ - type: "Cache", + type: "EnvironmentVariable", projectId, position: { x: 0, y: 0 }, properties: { - CacheName: "SessionCache", - Description: "Session cache", - KeyPattern: "session:{id}", - TTL_Seconds: 3600, - Engine: "Redis", - EvictionPolicy: "LRU", + Key: "DATABASE_URL", + Description: "DB connection", + DataType: "String", + IsSecret: false, + Environment: ["Prod"], + IsRequired: true, }, }); @@ -265,11 +266,11 @@ it("no project -> 404 ERR_PROJECT_NOT_FOUND", async () => { .expect(200); const data = res.body.data; -// skippedKinds Passed the full path Controller->Service->Repository->Neo4j. - expect(data.summary.skippedKinds).toEqual({ Cache: 1 }); -// The relevant stub file should be generated. + expect(data.summary.skippedKinds).toEqual({ EnvironmentVariable: 1 }); const paths: string[] = data.files.map((f: { path: string }) => f.path); - expect(paths.some((p) => p.endsWith(".cache.stub.ts"))).toBe(true); + expect(paths.some((p) => p.endsWith(".stub.ts"))).toBe(false); + const envExample = data.files.find((f: { path: string }) => f.path === ".env.example"); + expect(envExample?.content).toContain("DATABASE_URL"); }); it("DETERMINISM: same graph generated twice -> byte-identical files", async () => {