# fk installer — Windows. Self-contained: safe to run via # # irm https://install.filiokit.com/install.ps1 | iex # # Interactive bootstrap for a fresh org deployment. Downloads the compose stack # + Caddyfile from the install host, generates .env at $InstallDir, brings the # stack up, applies migrations, seeds defaults, registers the first admin. [CmdletBinding()] param( [string]$InstallDir = "C:\fk", [string]$AssetBase = $(if ($env:FK_ASSET_BASE) { $env:FK_ASSET_BASE } else { "https://install.filiokit.com" }) ) $ErrorActionPreference = 'Stop' function Write-Step($msg) { Write-Host "`n== $msg ==" -ForegroundColor Cyan } function Write-Ok($msg) { Write-Host " $msg" -ForegroundColor Green } function Write-Warn2($msg) { Write-Host " $msg" -ForegroundColor Yellow } function Write-Err2($msg) { Write-Host " $msg" -ForegroundColor Red } function Prompt-Default($label, $default) { $val = Read-Host "$label [$default]" if ([string]::IsNullOrWhiteSpace($val)) { return $default } else { return $val } } function Prompt-Required($label) { while ($true) { $val = Read-Host $label if (-not [string]::IsNullOrWhiteSpace($val)) { return $val } Write-Warn2 " required." } } function Prompt-Secret($label) { $sec = Read-Host -AsSecureString $label if ($sec.Length -eq 0) { return '' } $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } function Confirm-YN($label, $default = 'y') { $val = Read-Host "$label [$default]" if ([string]::IsNullOrWhiteSpace($val)) { $val = $default } return $val -match '^[yY]' } function New-Secret([int]$bytes = 32) { $buf = New-Object byte[] $bytes [System.Security.Cryptography.RandomNumberGenerator]::Fill($buf) return ([Convert]::ToBase64String($buf)).TrimEnd('=').Substring(0, $bytes) } function Fetch($url, $dest) { try { Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing -ErrorAction Stop } catch { Write-Err2 "failed to download $url"; exit 1 } } Write-Step "fk installer" Write-Host "Install: $InstallDir" Write-Host "Assets: $AssetBase" if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Err2 "docker not found in PATH"; exit 1 } $envFile = Join-Path $InstallDir '.env' if (Test-Path $envFile) { Write-Warn2 "An existing install was found at $InstallDir." if (Confirm-YN "Refresh stack only (pull images + migrate, keep secrets)" 'y') { Push-Location $InstallDir try { Write-Warn2 "Pull uses your existing registry login. If it fails with a 401, run: docker login ghcr.io" Fetch "$($AssetBase.TrimEnd('/'))/docker-compose.yml" (Join-Path $InstallDir 'docker-compose.yml') Fetch "$($AssetBase.TrimEnd('/'))/Caddyfile" (Join-Path $InstallDir 'Caddyfile') docker compose pull docker compose up -d docker compose exec -T web sh -c 'pnpm --filter @fk/db migrate:deploy' Write-Ok "OK refresh done" } finally { Pop-Location } exit 0 } Write-Err2 "Aborting to avoid clobbering existing secrets." exit 1 } New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'storage') | Out-Null Write-Step "1) Domains" $CRM_DOMAIN = Prompt-Default "CRM hostname" "crm.example.com" $CLIENT_DOMAIN = Prompt-Default "Client portal hostname" "client.example.com" Write-Step "2) Licensing (from your fk Superadmin contact)" $SUPERADMIN_URL = Prompt-Default "Superadmin URL" "https://superadmin.filiokit.com" $ORG_ID = Prompt-Required "Org ID" $LICENSE_KEY = Prompt-Required "License key" $LICENSE_SHARED_SECRET = Prompt-Secret "License shared secret (HMAC)" $LICENSE_PUBLIC_KEYS_JSON = "" $LICENSE_PUBLIC_KEY_PEM = "" Write-Host "Fetching the license public-key bundle from $SUPERADMIN_URL ..." try { $resp = Invoke-WebRequest -Uri "$($SUPERADMIN_URL.TrimEnd('/'))/v1/license/public-keys" -Method Get -TimeoutSec 15 -UseBasicParsing -ErrorAction Stop $raw = ($resp.Content).Trim() if (-not $raw.StartsWith('[')) { throw "unexpected response (not a JSON array)" } $LICENSE_PUBLIC_KEYS_JSON = $raw Write-Ok " + fetched public-key bundle automatically" } catch { Write-Warn2 " ! couldn't fetch - paste the Ed25519 public key PEM (end with a blank line):" $pemLines = New-Object System.Collections.Generic.List[string] while ($true) { $line = [Console]::ReadLine() if ([string]::IsNullOrEmpty($line)) { break } $pemLines.Add($line) } $LICENSE_PUBLIC_KEY_PEM = ($pemLines -join "`n") + "`n" } Write-Step "3) Database (Postgres)" $PG_USER = Prompt-Default "Postgres user" "fk" $PG_PASSWORD = Prompt-Secret "Postgres password (blank to auto-generate)" if ([string]::IsNullOrEmpty($PG_PASSWORD)) { $PG_PASSWORD = New-Secret 24 } $PG_DB = Prompt-Default "Postgres database name" "fk" Write-Step "4) Crypto" Write-Host "KEY_VAULT_MASTER_KEY encrypts SMTP passwords and API keys at rest." $KEY_VAULT_MASTER_KEY = Prompt-Secret "Master key (blank to auto-generate)" if ([string]::IsNullOrEmpty($KEY_VAULT_MASTER_KEY)) { $KEY_VAULT_MASTER_KEY = New-Secret 48 } $AUTH_SECRET = New-Secret 48 Write-Step "5) First admin user" $ADMIN_EMAIL = Prompt-Required "Admin email" $ADMIN_NAME = Prompt-Default "Admin full name" "Admin" $ADMIN_PASSWORD = Prompt-Secret "Admin password (min 12 chars)" Write-Step "6) Optional: SMTP for outgoing email (skip with empty host)" $SMTP_HOST = Prompt-Default "SMTP host" "" $SMTP_PORT = Prompt-Default "SMTP port" "587" $SMTP_USER = Prompt-Default "SMTP username" "" $SMTP_PASSWORD = Prompt-Secret "SMTP password" $SMTP_FROM = Prompt-Default "From address" "" Write-Step "7) Image registry (images are private on GHCR)" Write-Host "Provide a GitHub token with the read:packages scope so this server can pull the images." $IMAGE_REGISTRY = Prompt-Default "Image registry" "ghcr.io/iodex99" $REGISTRY_USERNAME = Prompt-Default "GitHub username (for image pulls)" "" $REGISTRY_TOKEN = Prompt-Secret "GitHub token (read:packages)" Write-Step "Writing .env" $pemEscaped = ($LICENSE_PUBLIC_KEY_PEM -replace "`r?`n", '\n') $publicKeyEnvLine = if (-not [string]::IsNullOrEmpty($LICENSE_PUBLIC_KEYS_JSON)) { "LICENSE_PUBLIC_KEYS_JSON='$LICENSE_PUBLIC_KEYS_JSON'" } else { "LICENSE_PUBLIC_KEY_PEM=`"$pemEscaped`"" } $AppUrl = "https://$CRM_DOMAIN" $ClientAppUrl = "https://$CLIENT_DOMAIN" $envBody = @" # Generated by install.ps1 on $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) NODE_ENV=production CRM_DOMAIN=$CRM_DOMAIN CLIENT_DOMAIN=$CLIENT_DOMAIN APP_URL=$AppUrl NEXT_PUBLIC_APP_URL=$AppUrl # Base URL of the client portal — used in client-facing emails (document-request login link). CLIENT_APP_URL=$ClientAppUrl DATABASE_URL=postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}?schema=public PG_USER=$PG_USER PG_PASSWORD=$PG_PASSWORD PG_DB=$PG_DB REDIS_URL=redis://redis:6379 KEY_VAULT_MASTER_KEY=$KEY_VAULT_MASTER_KEY AUTH_SECRET=$AUTH_SECRET SUPERADMIN_URL=$SUPERADMIN_URL ORG_ID=$ORG_ID LICENSE_KEY=$LICENSE_KEY LICENSE_SHARED_SECRET=$LICENSE_SHARED_SECRET $publicKeyEnvLine STORAGE_ROOT=/storage IMAGE_REGISTRY=$IMAGE_REGISTRY IMAGE_TAG=latest COMPOSE_PROJECT_NAME=fk BOOTSTRAP_ADMIN_EMAIL=$ADMIN_EMAIL BOOTSTRAP_ADMIN_NAME=$ADMIN_NAME BOOTSTRAP_ADMIN_PASSWORD=$ADMIN_PASSWORD "@ if (-not [string]::IsNullOrEmpty($SMTP_HOST)) { $envBody += @" BOOTSTRAP_SMTP_HOST=$SMTP_HOST BOOTSTRAP_SMTP_PORT=$SMTP_PORT BOOTSTRAP_SMTP_USER=$SMTP_USER BOOTSTRAP_SMTP_PASSWORD=$SMTP_PASSWORD BOOTSTRAP_SMTP_FROM=$SMTP_FROM "@ } Set-Content -Path $envFile -Value $envBody -NoNewline -Encoding utf8 Write-Ok "-> $envFile" Write-Step "Downloading stack files" Fetch "$($AssetBase.TrimEnd('/'))/docker-compose.yml" (Join-Path $InstallDir 'docker-compose.yml') Fetch "$($AssetBase.TrimEnd('/'))/Caddyfile" (Join-Path $InstallDir 'Caddyfile') Write-Ok "-> $(Join-Path $InstallDir 'docker-compose.yml')" Write-Step "Pulling images + starting stack" Push-Location $InstallDir try { if ($REGISTRY_TOKEN) { $registryHost = $IMAGE_REGISTRY.Split('/')[0] $REGISTRY_TOKEN | docker login $registryHost -u $REGISTRY_USERNAME --password-stdin } docker compose pull docker compose up -d Write-Host "Waiting for postgres to accept connections..." for ($i = 0; $i -lt 30; $i++) { try { docker compose exec -T postgres pg_isready -U $PG_USER | Out-Null if ($LASTEXITCODE -eq 0) { break } } catch {} Start-Sleep -Seconds 1 } Write-Step "Applying migrations + seeding" docker compose exec -T web sh -c 'pnpm --filter @fk/db migrate:deploy' docker compose exec -T web sh -c 'pnpm --filter @fk/db bootstrap' Write-Step "First license check" docker compose exec -T web sh -c 'curl -fsS -X POST http://localhost:3000/api/license/check' if ($LASTEXITCODE -eq 0) { Write-Ok "OK license check" } else { Write-Warn2 "! license check failed - verify superadmin connectivity from inside the container" } } finally { Pop-Location } Write-Host "" Write-Host "fk is up" -ForegroundColor Green Write-Host " CRM: https://$CRM_DOMAIN" Write-Host " Client portal: https://$CLIENT_DOMAIN" Write-Host " Logs: docker compose -f $InstallDir\docker-compose.yml logs -f" Write-Host " Stop: docker compose -f $InstallDir\docker-compose.yml down"