[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 }