start.ps1
Rama main · réplica de lectura del repositorio original.
[CmdletBinding()]
param(
[switch]$Check,
[switch]$ResumeAfterL0
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
function New-RandomHex([int]$byteCount) {
$bytes = New-Object byte[] $byteCount
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($bytes) }
finally { $generator.Dispose() }
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
}
function Get-EnvironmentEntry([string]$path, [string]$key) {
$matches = @([System.IO.File]::ReadAllLines($path) | Where-Object { $_.StartsWith("$key=") })
if ($matches.Count -gt 1) { throw "Duplicate $key entries in $path" }
if ($matches.Count -eq 0) { return @{ Exists = $false; Value = '' } }
return @{ Exists = $true; Value = $matches[0].Substring($key.Length + 1) }
}
function Set-EnvironmentValue([string]$path, [string]$key, [string]$value) {
$lines = @([System.IO.File]::ReadAllLines($path))
$index = -1
for ($position = 0; $position -lt $lines.Count; $position++) {
if ($lines[$position].StartsWith("$key=")) {
if ($index -ge 0) { throw "Duplicate $key entries in $path" }
$index = $position
}
}
$entry = "$key=$value"
if ($index -ge 0) { $lines[$index] = $entry } else { $lines += $entry }
[System.IO.File]::WriteAllLines($path, [string[]]$lines, $utf8NoBom)
}
function Test-PostgresIdentifier([string]$value) {
return $value -cmatch '^[a-z_][a-z0-9_]{0,62}$'
}
function Test-PostgresPassword([string]$value) {
$lowered = $value.ToLowerInvariant()
$distinct = @($value.ToCharArray() | Select-Object -Unique).Count
return $value -match '^[A-Za-z0-9._~-]{16,}$' -and
$distinct -ge 8 -and
-not $lowered.Contains('placeholder') -and
-not $lowered.Contains('change_me') -and
$lowered -ne 'your_strong_password_here'
}
function Assert-L0Configuration([string]$path) {
$user = (Get-EnvironmentEntry $path 'POSTGRES_USER').Value
$database = (Get-EnvironmentEntry $path 'POSTGRES_DB').Value
$password = (Get-EnvironmentEntry $path 'POSTGRES_PASSWORD').Value
if (-not (Test-PostgresIdentifier $user)) {
throw 'POSTGRES_USER must be a lowercase PostgreSQL identifier (1-63 characters).'
}
if (-not (Test-PostgresIdentifier $database)) {
throw 'POSTGRES_DB must be a lowercase PostgreSQL identifier (1-63 characters).'
}
if (-not (Test-PostgresPassword $password)) {
throw 'POSTGRES_PASSWORD must be URL-safe, non-placeholder, at least 16 characters, and contain at least 8 distinct characters.'
}
}
function Initialize-L0Configuration([string]$path, [bool]$allowPrompt) {
$marker = Get-EnvironmentEntry $path 'BOOTSTRAP_L0_COMPLETE'
if ($marker.Exists -and $marker.Value -notin @('true', 'false')) {
throw 'BOOTSTRAP_L0_COMPLETE must be exactly true or false.'
}
if ($marker.Value -eq 'true') {
Assert-L0Configuration $path
return $false
}
$user = (Get-EnvironmentEntry $path 'POSTGRES_USER').Value
$database = (Get-EnvironmentEntry $path 'POSTGRES_DB').Value
$password = (Get-EnvironmentEntry $path 'POSTGRES_PASSWORD').Value
if ((Test-PostgresIdentifier $user) -and
(Test-PostgresIdentifier $database) -and
(Test-PostgresPassword $password)) {
# Existing and automation-preseeded installations migrate without a prompt.
Set-EnvironmentValue $path 'BOOTSTRAP_L0_COMPLETE' 'true'
return $false
}
if (-not $allowPrompt -or [Console]::IsInputRedirected) {
throw 'L0 database setup is incomplete. Run start.cmd interactively, or preseed POSTGRES_USER, POSTGRES_DB, and a strong POSTGRES_PASSWORD in .env.'
}
Write-Host ''
Write-Host 'First launch: configure required local PostgreSQL (L0)' -ForegroundColor Cyan
Write-Host 'The database password is generated automatically. Model, Redis, and object storage settings can wait.'
$defaultUser = if (Test-PostgresIdentifier $user) { $user } else { 'novel' }
$defaultDatabase = if (Test-PostgresIdentifier $database) { $database } else { 'novel_world' }
$enteredUser = Read-Host "PostgreSQL user [$defaultUser]"
$enteredDatabase = Read-Host "PostgreSQL database [$defaultDatabase]"
if ([string]::IsNullOrWhiteSpace($enteredUser)) { $enteredUser = $defaultUser }
if ([string]::IsNullOrWhiteSpace($enteredDatabase)) { $enteredDatabase = $defaultDatabase }
if (-not (Test-PostgresIdentifier $enteredUser)) {
throw 'PostgreSQL user must be 1-63 lowercase letters, digits, or underscores and cannot start with a digit.'
}
if (-not (Test-PostgresIdentifier $enteredDatabase)) {
throw 'PostgreSQL database must be 1-63 lowercase letters, digits, or underscores and cannot start with a digit.'
}
Set-EnvironmentValue $path 'POSTGRES_USER' $enteredUser
Set-EnvironmentValue $path 'POSTGRES_DB' $enteredDatabase
Set-EnvironmentValue $path 'POSTGRES_PASSWORD' (New-RandomHex 16)
# Commit point: never mark L0 complete until every hard database value is persisted.
Set-EnvironmentValue $path 'BOOTSTRAP_L0_COMPLETE' 'true'
Assert-L0Configuration $path
return $true
}
function Test-RedisPassword([string]$value) {
$lowered = $value.ToLowerInvariant()
$distinct = @($value.ToCharArray() | Select-Object -Unique).Count
return $value -match '^[A-Za-z0-9._~-]{16,}$' -and
$distinct -ge 8 -and
-not $lowered.Contains('placeholder') -and
-not $lowered.Contains('change_me') -and
$lowered -ne 'your_redis_password_here' -and
$lowered -ne 'runtime-redis-only'
}
function Resolve-CacheMode([string]$path) {
$modeEntry = Get-EnvironmentEntry $path 'CACHE_MODE'
$redisEntry = Get-EnvironmentEntry $path 'REDIS_PASSWORD'
if (-not $modeEntry.Exists) {
# Old launchers always generated a Redis password. New templates persist
# postgres before this compatibility migration runs.
$legacyRedis = -not [string]::IsNullOrEmpty($redisEntry.Value) -and
$redisEntry.Value -ne 'your_redis_password_here'
$mode = if ($legacyRedis) { 'redis' } else { 'postgres' }
Set-EnvironmentValue $path 'CACHE_MODE' $mode
if (-not $legacyRedis -and $redisEntry.Value -eq 'your_redis_password_here') {
Set-EnvironmentValue $path 'REDIS_PASSWORD' ''
}
}
else { $mode = $modeEntry.Value }
if ($mode -notin @('postgres', 'redis')) {
throw 'CACHE_MODE must be exactly postgres or redis.'
}
if ($mode -eq 'redis' -and -not (Test-RedisPassword $redisEntry.Value)) {
throw 'CACHE_MODE=redis requires a URL-safe, non-placeholder REDIS_PASSWORD of at least 16 characters with 8 distinct characters.'
}
return $mode
}
function Initialize-Environment([string]$path) {
$null = Resolve-CacheMode $path
$secrets = @(
@{ Key = 'JWT_SECRET'; Placeholder = 'change_me_to_a_random_32_char_string'; Bytes = 32 },
@{ Key = 'RUNTIME_CONFIG_KEY'; Placeholder = 'change_me_to_a_random_64_char_hex_string'; Bytes = 32 },
@{ Key = 'INTERNAL_SERVICE_TOKEN'; Placeholder = 'change_me_to_a_random_internal_service_token'; Bytes = 32 }
)
foreach ($secret in $secrets) {
$entry = Get-EnvironmentEntry $path ([string]$secret.Key)
if ([string]::IsNullOrEmpty($entry.Value) -or $entry.Value -eq $secret.Placeholder) {
Set-EnvironmentValue $path ([string]$secret.Key) (New-RandomHex ([int]$secret.Bytes))
}
}
foreach ($key in @('LLM_API_KEY', 'IMAGE_GEN_API_KEY')) {
$entry = Get-EnvironmentEntry $path $key
if ($entry.Value -eq 'sk-your-api-key') { Set-EnvironmentValue $path $key '' }
}
}
function Test-EnvironmentInitialization {
$temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "novelworld-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null
try {
$freshEnv = Join-Path $temporaryDirectory 'fresh.env'
Copy-Item (Join-Path $scriptRoot '.env.example') $freshEnv
$rejected = $false
try { $null = Initialize-L0Configuration $freshEnv $false } catch { $rejected = $true }
if (-not $rejected) { throw 'An unconfigured non-interactive L0 launch was accepted.' }
$seededPassword = '0123456789abcdef0123456789abcdef'
Set-EnvironmentValue $freshEnv 'POSTGRES_USER' 'novel'
Set-EnvironmentValue $freshEnv 'POSTGRES_DB' 'novel_world'
Set-EnvironmentValue $freshEnv 'POSTGRES_PASSWORD' $seededPassword
$configuredInteractively = Initialize-L0Configuration $freshEnv $false
if ($configuredInteractively) { throw 'A valid preseed unexpectedly requested interactive setup.' }
Initialize-Environment $freshEnv
$content = [System.IO.File]::ReadAllText($freshEnv)
foreach ($expected in @(
@{ Key = 'JWT_SECRET'; Length = 64 },
@{ Key = 'RUNTIME_CONFIG_KEY'; Length = 64 },
@{ Key = 'INTERNAL_SERVICE_TOKEN'; Length = 64 }
)) {
if ($content -notmatch "(?m)^$($expected.Key)=[0-9a-f]{$($expected.Length)}\r?$") {
throw "Failed to generate $($expected.Key)"
}
}
if ($content -notmatch '(?m)^BOOTSTRAP_L0_COMPLETE=true\r?$' -or
(Get-EnvironmentEntry $freshEnv 'POSTGRES_PASSWORD').Value -ne $seededPassword) {
throw 'A valid preseed must be committed without replacing its database password.'
}
if ($content -notmatch '(?m)^CACHE_MODE=postgres\r?$' -or
$content -notmatch '(?m)^REDIS_PASSWORD=\r?$') {
throw 'Fresh installs must persist postgres mode without generating Redis credentials.'
}
if ($content -notmatch '(?m)^LLM_API_KEY=\r?$') {
throw 'The default LLM key must remain empty for browser setup.'
}
$invalidCommittedEnv = Join-Path $temporaryDirectory 'invalid-committed.env'
Copy-Item (Join-Path $scriptRoot '.env.example') $invalidCommittedEnv
Set-EnvironmentValue $invalidCommittedEnv 'BOOTSTRAP_L0_COMPLETE' 'true'
$rejected = $false
try { $null = Initialize-L0Configuration $invalidCommittedEnv $false } catch { $rejected = $true }
if (-not $rejected) { throw 'A committed L0 marker bypassed database validation.' }
$legacyEnv = Join-Path $temporaryDirectory 'legacy.env'
$legacyLines = @([System.IO.File]::ReadAllLines((Join-Path $scriptRoot '.env.example')) |
Where-Object { -not $_.StartsWith('CACHE_MODE=') })
for ($position = 0; $position -lt $legacyLines.Count; $position++) {
if ($legacyLines[$position].StartsWith('REDIS_PASSWORD=')) {
$legacyLines[$position] = 'REDIS_PASSWORD=0123456789abcdef0123456789abcdef'
}
}
[System.IO.File]::WriteAllLines($legacyEnv, [string[]]$legacyLines, $utf8NoBom)
Initialize-Environment $legacyEnv
if ((Get-EnvironmentEntry $legacyEnv 'CACHE_MODE').Value -ne 'redis') {
throw 'A pre-decision environment with a Redis credential must migrate once to redis mode.'
}
$halfSelectedEnv = Join-Path $temporaryDirectory 'half-selected.env'
Copy-Item (Join-Path $scriptRoot '.env.example') $halfSelectedEnv
Set-EnvironmentValue $halfSelectedEnv 'CACHE_MODE' 'redis'
$rejected = $false
try { $null = Resolve-CacheMode $halfSelectedEnv } catch { $rejected = $true }
if (-not $rejected) { throw 'A half-selected Redis mode was accepted.' }
Set-EnvironmentValue $halfSelectedEnv 'REDIS_PASSWORD' 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
$rejected = $false
try { $null = Resolve-CacheMode $halfSelectedEnv } catch { $rejected = $true }
if (-not $rejected) { throw 'A low-entropy Redis credential was accepted.' }
$windowsLauncher = [System.IO.File]::ReadAllText((Join-Path $scriptRoot 'start.ps1'))
if ($windowsLauncher -notmatch '(?s)BOOTSTRAP_L0_COMPLETE.*ResumeAfterL0.*powershell\.exe.*-ResumeAfterL0') {
throw 'Windows launcher must commit L0 and automatically restart itself.'
}
if ($windowsLauncher -notmatch '(?s)compose.*--profile.*redis.*down.*composeArgs.*--wait') {
throw 'Windows launcher must stop old writers and wait for selected-profile readiness.'
}
$unixLauncher = [System.IO.File]::ReadAllText((Join-Path $scriptRoot 'start.sh'))
if ($unixLauncher -notmatch '(?s)docker compose --profile redis down.*--wait --wait-timeout 180') {
throw 'Unix launcher must stop old writers and wait for selected-profile readiness.'
}
$compose = [System.IO.File]::ReadAllText((Join-Path $scriptRoot 'docker-compose.yml'))
if ($compose -notmatch 'REDIS_PASSWORD must be URL-safe.*8 distinct characters') {
throw 'The Redis profile must reject a direct half-selected or weak credential before Redis starts.'
}
}
finally { Remove-Item $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue }
Write-Host 'Windows launcher self-check passed.' -ForegroundColor Green
}
if ($Check) {
Test-EnvironmentInitialization
exit 0
}
Set-Location $scriptRoot
$envPath = Join-Path $scriptRoot '.env'
if (-not (Test-Path $envPath)) { Copy-Item (Join-Path $scriptRoot '.env.example') $envPath }
$l0Configured = Initialize-L0Configuration $envPath $true
if ($l0Configured) {
if ($ResumeAfterL0) { throw 'L0 setup restart loop detected.' }
Write-Host 'L0 database settings saved. Restarting the launcher automatically...' -ForegroundColor Green
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath -ResumeAfterL0
exit $LASTEXITCODE
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw 'Docker Desktop is not installed. Install it from https://docs.docker.com/desktop/setup/install/windows-install/'
}
& docker compose version *> $null
if ($LASTEXITCODE -ne 0) {
throw 'Docker Compose v2 is not available. Start or update Docker Desktop.'
}
Initialize-Environment $envPath
$cacheMode = Resolve-CacheMode $envPath
$env:CACHE_MODE = $cacheMode
$env:COMPOSE_PROFILES = ''
$composeArgs = @('compose')
if ($cacheMode -eq 'redis') {
$redisPassword = (Get-EnvironmentEntry $envPath 'REDIS_PASSWORD').Value
$env:REDIS_PASSWORD = $redisPassword
$env:REDIS_URL = "redis://:${redisPassword}@redis:6379"
$composeArgs += @('--profile', 'redis')
}
else {
$env:REDIS_PASSWORD = ''
$env:REDIS_URL = 'memory://'
}
Write-Host 'Stopping any existing NovelWorld stack before migrations...' -ForegroundColor Cyan
& docker compose --profile redis down
if ($LASTEXITCODE -ne 0) { throw 'Docker Compose failed to stop the existing NovelWorld stack.' }
Write-Host "Starting NovelWorld (cache: $cacheMode)..." -ForegroundColor Cyan
$composeArgs += @('up', '-d', '--build', '--wait', '--wait-timeout', '180')
& docker @composeArgs
if ($LASTEXITCODE -ne 0) { throw 'Docker Compose did not make NovelWorld ready.' }
Write-Host 'NovelWorld is ready at http://localhost' -ForegroundColor Green
Write-Host 'Stop: docker compose --profile redis down'
Write-Host 'Logs: docker compose logs -f'
try { Start-Process 'http://localhost' }
catch { Write-Warning 'Open http://localhost in your browser.' }