Ctrl K
ComunidadGuías…

start.ps1

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
[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.' }