diff --git a/scripts/chorus-dev.bat b/scripts/chorus-dev.bat new file mode 100644 index 0000000..efbe708 --- /dev/null +++ b/scripts/chorus-dev.bat @@ -0,0 +1,9 @@ +@echo off +setlocal + +set "CHORUS_SHELL=pwsh.exe" +where "%CHORUS_SHELL%" >nul 2>nul +if errorlevel 1 set "CHORUS_SHELL=powershell.exe" + +"%CHORUS_SHELL%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0chorus-dev.ps1" %* +exit /b %ERRORLEVEL% diff --git a/scripts/chorus-dev.ps1 b/scripts/chorus-dev.ps1 new file mode 100644 index 0000000..4d81066 --- /dev/null +++ b/scripts/chorus-dev.ps1 @@ -0,0 +1,374 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet("start", "stop", "restart", "status", "start-mock")] + [string]$Command = "start", + + [Parameter(Position = 1)] + [string]$EnvironmentFile +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is required." +} + +$script:RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) +$script:EnvironmentFile = $EnvironmentFile +$script:StateRoot = Join-Path $env:LOCALAPPDATA "Chorus\dev" +$script:BinRoot = Join-Path $script:StateRoot "bin" +$script:LogRoot = Join-Path $script:StateRoot "logs" + +function Write-Step { + param([string]$Message) + Write-Host "[chorus] $Message" +} + +function Get-StatePath { + param([ValidateSet("portal", "mock")][string]$Name) + return Join-Path $script:StateRoot "$Name.json" +} + +function Read-State { + param([ValidateSet("portal", "mock")][string]$Name) + $path = Get-StatePath $Name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + return $null + } + try { + return Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + } + catch { + throw "State file is invalid: $path" + } +} + +function Remove-State { + param([ValidateSet("portal", "mock")][string]$Name) + $path = Get-StatePath $Name + if (Test-Path -LiteralPath $path -PathType Leaf) { + Remove-Item -LiteralPath $path -Force + } +} + +function Get-ManagedProcess { + param( + [ValidateSet("portal", "mock")][string]$Name, + [switch]$Quiet + ) + $state = Read-State $Name + if ($null -eq $state) { + return $null + } + $process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue + if ($null -eq $process) { + Remove-State $Name + return $null + } + $expected = [IO.Path]::GetFullPath([string]$state.executable) + $actual = $null + try { + $actual = [IO.Path]::GetFullPath([string]$process.Path) + } + catch { + if (-not $Quiet) { + throw "Cannot verify executable path for $Name process $($state.pid); refusing to manage it." + } + return $null + } + if (-not [string]::Equals($expected, $actual, [StringComparison]::OrdinalIgnoreCase)) { + if (-not $Quiet) { + throw "PID $($state.pid) does not run the recorded $Name executable; refusing to manage it." + } + return $null + } + return [pscustomobject]@{ State = $state; Process = $process } +} + +function Import-ChorusEnvironment { + if ([string]::IsNullOrWhiteSpace($EnvironmentFile)) { + if (-not [string]::IsNullOrWhiteSpace($env:CHORUS_ENV_FILE)) { + $script:EnvironmentFile = $env:CHORUS_ENV_FILE + } + else { + $script:EnvironmentFile = Join-Path (Split-Path $script:RepoRoot -Parent) "chorus-tools\chorus-test.env.ps1" + } + } + $resolved = Resolve-Path -LiteralPath $script:EnvironmentFile -ErrorAction SilentlyContinue + if ($null -eq $resolved) { + throw "Environment file not found: $script:EnvironmentFile" + } + $script:EnvironmentFile = $resolved.Path + . $script:EnvironmentFile + if ([string]::IsNullOrWhiteSpace($env:CHORUS_DSN)) { + throw "CHORUS_DSN is required in the environment file." + } +} + +function Get-GoCommand { + if (-not [string]::IsNullOrWhiteSpace($env:CHORUS_GO)) { + $candidate = Resolve-Path -LiteralPath $env:CHORUS_GO -ErrorAction SilentlyContinue + if ($null -eq $candidate -or -not (Test-Path -LiteralPath $candidate.Path -PathType Leaf)) { + throw "CHORUS_GO does not point to an executable file." + } + return $candidate.Path + } + $command = Get-Command go.exe -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + $portable = Join-Path (Split-Path $script:RepoRoot -Parent) "chorus-tools\go1.26.5\bin\go.exe" + if (Test-Path -LiteralPath $portable -PathType Leaf) { + return $portable + } + throw "Go was not found. Add go.exe to PATH or set CHORUS_GO." +} + +function Get-Endpoint { + param( + [string]$Value, + [string]$Fallback, + [string]$Name + ) + if ([string]::IsNullOrWhiteSpace($Value)) { + $Value = $Fallback + } + if ($Value -notmatch '^(?127\.0\.0\.1|localhost|\[::1\]):(?\d{1,5})$') { + throw "$Name must use a loopback host and explicit port." + } + $port = [int]$Matches.port + if ($port -lt 1 -or $port -gt 65535) { + throw "$Name port is outside 1-65535." + } + return [pscustomobject]@{ Address = $Value; Host = $Matches.host.Trim('[', ']'); Port = $port } +} + +function Test-TcpPort { + param([string]$HostName, [int]$Port, [int]$TimeoutMilliseconds = 500) + $client = [Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync($HostName, $Port) + if (-not $task.Wait($TimeoutMilliseconds)) { + return $false + } + return $client.Connected + } + catch { + return $false + } + finally { + $client.Dispose() + } +} + +function Assert-DatabaseAvailable { + $hostName = $env:CHORUS_MYSQL_HOST + $portText = $env:CHORUS_MYSQL_PORT + if ([string]::IsNullOrWhiteSpace($hostName) -or [string]::IsNullOrWhiteSpace($portText)) { + if ($env:CHORUS_DSN -notmatch '@tcp\((?[^:()]+):(?\d+)\)') { + throw "Set CHORUS_MYSQL_HOST and CHORUS_MYSQL_PORT, or use a TCP CHORUS_DSN." + } + $hostName = $Matches.host + $portText = $Matches.port + } + if ($hostName -notin @("127.0.0.1", "localhost", "::1")) { + throw "The development launcher only accepts a local MySQL host." + } + $port = 0 + if (-not [int]::TryParse($portText, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { + throw "CHORUS_MYSQL_PORT is invalid." + } + if (-not (Test-TcpPort $hostName $port 2000)) { + throw "Local MySQL is not reachable on the configured host and port." + } +} + +function Assert-PortAvailable { + param($Endpoint, [string]$Name) + if (Test-TcpPort $Endpoint.Host $Endpoint.Port) { + throw "$Name cannot start because $($Endpoint.Address) is already in use." + } +} + +function Build-Component { + param( + [ValidateSet("portal", "mock")][string]$Name, + [string]$Package + ) + $go = Get-GoCommand + New-Item -ItemType Directory -Path $script:BinRoot -Force | Out-Null + $output = Join-Path $script:BinRoot "chorus-$Name.exe" + Write-Step "Building $Name..." + Push-Location $script:RepoRoot + try { + & $go build -o $output $Package + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) { + throw "Failed to build $Name." + } + } + finally { + Pop-Location + } + return [IO.Path]::GetFullPath($output) +} + +function Wait-Portal { + param($Endpoint, [Diagnostics.Process]$Process, [string]$ErrorLog) + $urlHost = if ($Endpoint.Host -eq "::1") { "[::1]" } else { $Endpoint.Host } + $url = "http://${urlHost}:$($Endpoint.Port)/login" + $handler = [Net.Http.HttpClientHandler]::new() + $handler.UseProxy = $false + $client = [Net.Http.HttpClient]::new($handler) + $client.Timeout = [TimeSpan]::FromSeconds(2) + try { + for ($attempt = 0; $attempt -lt 30; $attempt++) { + if ($Process.HasExited) { + throw "Portal exited during startup. See $ErrorLog" + } + try { + $response = $client.GetAsync($url).GetAwaiter().GetResult() + if ([int]$response.StatusCode -eq 200) { + $response.Dispose() + return $url + } + $response.Dispose() + } + catch { + Start-Sleep -Milliseconds 300 + } + } + throw "Portal health check timed out. See $ErrorLog" + } + finally { + $client.Dispose() + $handler.Dispose() + } +} + +function Start-Component { + param( + [ValidateSet("portal", "mock")][string]$Name, + [string]$Package, + $Endpoint + ) + $existing = Get-ManagedProcess $Name + if ($null -ne $existing) { + Write-Step "$Name is already running (PID $($existing.State.pid))." + return + } + Assert-PortAvailable $Endpoint $Name + $executable = Build-Component $Name $Package + New-Item -ItemType Directory -Path $script:LogRoot -Force | Out-Null + $stamp = Get-Date -Format "yyyyMMdd-HHmmss" + $stdout = Join-Path $script:LogRoot "$Name-$stamp.out.log" + $stderr = Join-Path $script:LogRoot "$Name-$stamp.err.log" + $process = Start-Process -FilePath $executable -WorkingDirectory $script:RepoRoot -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru -WindowStyle Hidden + $state = [ordered]@{ + name = $Name + pid = $process.Id + executable = $executable + address = $Endpoint.Address + stdout = $stdout + stderr = $stderr + started_at = (Get-Date).ToString("o") + } + $state | ConvertTo-Json | Set-Content -LiteralPath (Get-StatePath $Name) -Encoding UTF8 + try { + if ($Name -eq "portal") { + $url = Wait-Portal $Endpoint $process $stderr + Write-Step "Portal URL: $url" + } + else { + for ($attempt = 0; $attempt -lt 20; $attempt++) { + if ($process.HasExited) { + throw "Mock provider exited during startup. See $stderr" + } + if (Test-TcpPort $Endpoint.Host $Endpoint.Port) { + Write-Step "Mock provider listening on $($Endpoint.Address)." + break + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-TcpPort $Endpoint.Host $Endpoint.Port)) { + throw "Mock provider health check timed out. See $stderr" + } + } + } + catch { + if (-not $process.HasExited) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + Remove-State $Name + throw + } + Write-Step "$Name PID: $($process.Id)" + Write-Step "$Name logs: $stdout and $stderr" +} + +function Stop-Component { + param([ValidateSet("portal", "mock")][string]$Name) + $managed = Get-ManagedProcess $Name + if ($null -eq $managed) { + Write-Step "$name is not running." + return + } + Write-Step "Stopping $Name (PID $($managed.State.pid))..." + Stop-Process -Id $managed.Process.Id -Force + $managed.Process.WaitForExit(5000) | Out-Null + Remove-State $Name + Write-Step "$Name stopped." +} + +function Show-Status { + foreach ($name in @("portal", "mock")) { + $managed = Get-ManagedProcess $name -Quiet + if ($null -eq $managed) { + Write-Step "${name}: stopped" + } + else { + Write-Step "${name}: running (PID $($managed.State.pid), $($managed.State.address))" + Write-Step "$name logs: $($managed.State.stdout) and $($managed.State.stderr)" + } + } +} + +New-Item -ItemType Directory -Path $script:StateRoot -Force | Out-Null + +try { + switch ($Command) { + "status" { + Show-Status + } + "stop" { + Stop-Component "mock" + Stop-Component "portal" + } + "start" { + Import-ChorusEnvironment + Assert-DatabaseAvailable + $portalEndpoint = Get-Endpoint $env:CHORUS_LISTEN_ADDRESS "127.0.0.1:8080" "CHORUS_LISTEN_ADDRESS" + $env:CHORUS_LISTEN_ADDRESS = $portalEndpoint.Address + Start-Component "portal" "./portal" $portalEndpoint + } + "start-mock" { + Import-ChorusEnvironment + $mockEndpoint = Get-Endpoint $env:CHORUS_MOCK_ADDR "127.0.0.1:18080" "CHORUS_MOCK_ADDR" + $env:CHORUS_MOCK_ADDR = $mockEndpoint.Address + Start-Component "mock" "./cmd/chorus-mock-provider" $mockEndpoint + } + "restart" { + Stop-Component "portal" + Import-ChorusEnvironment + Assert-DatabaseAvailable + $portalEndpoint = Get-Endpoint $env:CHORUS_LISTEN_ADDRESS "127.0.0.1:8080" "CHORUS_LISTEN_ADDRESS" + $env:CHORUS_LISTEN_ADDRESS = $portalEndpoint.Address + Start-Component "portal" "./portal" $portalEndpoint + } + } +} +catch { + [Console]::Error.WriteLine("[chorus] ERROR: " + $_.Exception.Message) + exit 1 +}