Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' $script:BellAllowedEnvironment = @( 'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT', 'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE', 'BELL_SYNTHETIC_EVENTS_ENABLED' ) function Get-BellPackageRoot { param([string]$ScriptDirectory = $PSScriptRoot) return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..')) } function Import-BellEnvironment { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell configuration file not found: $Path" } $lineNumber = 0 foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) { $lineNumber++ $line = $rawLine.Trim() if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue } $separator = $line.IndexOf('=') if ($separator -lt 1) { throw "Invalid Bell configuration at line $lineNumber. Expected NAME=value." } $name = $line.Substring(0, $separator).Trim() if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell configuration key at line ${lineNumber}: $name" } $value = $line.Substring($separator + 1) if ($value.Length -ge 2) { $first, $last = $value[0], $value[$value.Length - 1] if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) } } if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) { [Environment]::SetEnvironmentVariable($name, $value, 'Process') } } } function Get-BellEnvironmentValue { param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '') $value = [Environment]::GetEnvironmentVariable($Name, 'Process') if ([string]::IsNullOrWhiteSpace($value)) { return $Default } return $value } function Test-BellTcpEndpoint { param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000) $client = [Net.Sockets.TcpClient]::new() try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() } } function Test-BellListenPortAvailable { param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port) $ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) } $listener = [Net.Sockets.TcpListener]::new($ip, $Port) try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} } } function Get-BellDatabaseEndpoint { param([Parameter(Mandatory = $true)][string]$Connection) if ($Connection -match '^postgres(?:ql)?://') { $uri = [Uri]$Connection return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') } } $values = @{} foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?[A-Za-z_][A-Za-z0-9_]*)=(?''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) { $value = $match.Groups['value'].Value.Trim("'", '"') $values[$match.Groups['key'].Value.ToLowerInvariant()] = $value } if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' } return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname } } function Get-BellPort { param([string]$Name, [int]$Default) $text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString() $port = 0 if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." } return $port } function Initialize-BellRuntime { param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts) Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env') $hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1' $webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1' if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' } $port = Get-BellPort -Name 'BELL_PORT' -Default 18090 $webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091 if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' } if (-not $AllowOccupiedPorts) { if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." } if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." } } $databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL' if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' } $database = Get-BellDatabaseEndpoint -Connection $databaseURL if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' } if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." } $jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET' if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' } $webRoot = Join-Path $PackageRoot 'web' if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" } return [pscustomobject]@{ Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort; BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort"; SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot } } function Wait-BellHealth { param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100) for ($attempt = 0; $attempt -lt $Attempts; $attempt++) { try { $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy; if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } } catch {} Start-Sleep -Milliseconds 300 } throw "Bell health check timed out: $BaseUrl/healthz" }