[CmdletBinding()] param( [Parameter(Position = 0)] [ValidateSet("start", "stop", "status")] [string]$Command = "start", [Parameter(Position = 1)] [string]$ConfigFile ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { throw "LOCALAPPDATA is required." } $script:RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) if ([string]::IsNullOrWhiteSpace($ConfigFile)) { $ConfigFile = Join-Path $script:RepoRoot "config\local-services.yml" } $script:StateRoot = Join-Path $env:LOCALAPPDATA "Chorus\all" $script:BinRoot = Join-Path $script:StateRoot "bin" $script:LogRoot = Join-Path $script:StateRoot "logs" $script:PortalStatePath = Join-Path $env:LOCALAPPDATA "Chorus\dev\portal.json" function Write-Step { param([string]$Message) Write-Host "[chorus-all] $Message" } 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 Read-LocalServicesConfig { $go = Get-GoCommand Push-Location $script:RepoRoot try { $output = & $go run ./cmd/chorus-local-config -config $ConfigFile if ($LASTEXITCODE -ne 0) { throw "Local services configuration validation failed." } } finally { Pop-Location } try { return ($output -join [Environment]::NewLine) | ConvertFrom-Json } catch { throw "Local services configuration returned invalid JSON." } } function ConvertTo-Endpoint { param([string]$Address) if ($Address -notmatch '^\[?(?[^\]]+)\]?:(?\d+)$') { throw "Resolved service address is invalid." } return [pscustomobject]@{ Address = $Address; Host = $Matches.host; Port = [int]$Matches.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 Get-StatePath { param([ValidateSet("admin", "admin-ui")][string]$Name) return Join-Path $script:StateRoot "$Name.json" } function Read-State { param([ValidateSet("admin", "admin-ui")][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("admin", "admin-ui")][string]$Name) $path = Get-StatePath $Name if (Test-Path -LiteralPath $path -PathType Leaf) { Remove-Item -LiteralPath $path -Force } } function Get-ManagedProcess { param([ValidateSet("admin", "admin-ui")][string]$Name) $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 } try { $expected = [IO.Path]::GetFullPath([string]$state.executable) $actual = [IO.Path]::GetFullPath([string]$process.Path) } catch { throw "Cannot verify executable path for $Name process $($state.pid); refusing to manage it." } if (-not [string]::Equals($expected, $actual, [StringComparison]::OrdinalIgnoreCase)) { throw "PID $($state.pid) does not run the recorded $Name executable; refusing to manage it." } return [pscustomobject]@{ State = $state; Process = $process } } function Assert-PortAvailable { param($Endpoint, [string]$Name) if (Test-TcpPort $Endpoint.Host $Endpoint.Port) { throw "$Name cannot start because $($Endpoint.Address) is used by an unmanaged process." } } function Wait-Tcp { param($Endpoint, [Diagnostics.Process]$Process, [string]$ErrorLog, [string]$Name) for ($attempt = 0; $attempt -lt 60; $attempt++) { if ($Process.HasExited) { throw "$Name exited during startup. See $ErrorLog" } if (Test-TcpPort $Endpoint.Host $Endpoint.Port) { return } Start-Sleep -Milliseconds 250 } throw "$Name startup timed out. See $ErrorLog" } function Wait-AdminUI { param($Endpoint, [Diagnostics.Process]$Process, [string]$ErrorLog) $urlHost = if ($Endpoint.Host -eq "::1") { "[::1]" } else { $Endpoint.Host } $url = "http://${urlHost}:$($Endpoint.Port)/" $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 120; $attempt++) { if ($Process.HasExited) { throw "admin-ui 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 500 } } throw "admin-ui startup timed out. See $ErrorLog" } finally { $client.Dispose() $handler.Dispose() } } function Start-ManagedProcess { param( [ValidateSet("admin", "admin-ui")][string]$Name, [string]$Executable, [string[]]$Arguments, [string]$WorkingDirectory, $Endpoint, [switch]$HTTP ) $existing = Get-ManagedProcess $Name if ($null -ne $existing) { Write-Step "$Name is already running (PID $($existing.State.pid))." return $false } Assert-PortAvailable $Endpoint $Name 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 -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru -WindowStyle Hidden $state = [ordered]@{ name = $Name pid = $process.Id executable = [IO.Path]::GetFullPath($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 ($HTTP) { $url = Wait-AdminUI $Endpoint $process $stderr Write-Step "Admin UI URL: $url" } else { Wait-Tcp $Endpoint $process $stderr $Name Write-Step "$Name listening on $($Endpoint.Address)." } } 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" return $true } function Build-Admin { $go = Get-GoCommand New-Item -ItemType Directory -Path $script:BinRoot -Force | Out-Null $output = Join-Path $script:BinRoot "chorus-admin.exe" Write-Step "Building admin..." Push-Location $script:RepoRoot try { & $go -C admin build -o $output . if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) { throw "Failed to build admin." } } finally { Pop-Location } return [IO.Path]::GetFullPath($output) } function Start-Admin { param($Resolved) $endpoint = ConvertTo-Endpoint $Resolved.admin_address if ($null -ne (Get-ManagedProcess "admin")) { Write-Step "admin is already running." return $false } $executable = Build-Admin $settingsArgument = '"' + $Resolved.admin_settings_file.Replace('"', '\"') + '"' return Start-ManagedProcess "admin" $executable @("server", "--config", $settingsArgument) (Join-Path $script:RepoRoot "admin") $endpoint } function Start-AdminUI { param($Resolved) $endpoint = ConvertTo-Endpoint $Resolved.admin_ui_address if ($null -ne (Get-ManagedProcess "admin-ui")) { Write-Step "admin-ui is already running." return $false } $node = Get-Command node.exe -ErrorAction SilentlyContinue if ($null -eq $node) { throw "Node was not found. Install the locked Node version before starting admin-ui." } $entry = Join-Path $script:RepoRoot "admin-ui\node_modules\@vue\cli-service\bin\vue-cli-service.js" if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) { throw "admin-ui dependencies are missing. Run corepack pnpm --dir admin-ui install --frozen-lockfile." } $previous = $env:VUE_APP_BASE_API try { $env:VUE_APP_BASE_API = $Resolved.admin_ui_api_base $entryArgument = '"' + $entry.Replace('"', '\"') + '"' return Start-ManagedProcess "admin-ui" $node.Source @($entryArgument, "serve", "--host", $endpoint.Host, "--port", "$($endpoint.Port)") (Join-Path $script:RepoRoot "admin-ui") $endpoint -HTTP } finally { $env:VUE_APP_BASE_API = $previous } } function Test-PortalManaged { if (-not (Test-Path -LiteralPath $script:PortalStatePath -PathType Leaf)) { return $false } try { $state = Get-Content -LiteralPath $script:PortalStatePath -Raw | ConvertFrom-Json $process = Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue if ($null -eq $process) { return $false } return [string]::Equals([IO.Path]::GetFullPath([string]$state.executable), [IO.Path]::GetFullPath([string]$process.Path), [StringComparison]::OrdinalIgnoreCase) } catch { return $false } } function Start-Portal { param($Resolved) if (Test-PortalManaged) { Write-Step "portal is already running." return $false } $endpoint = ConvertTo-Endpoint $Resolved.portal_address Assert-PortAvailable $endpoint "portal" & (Join-Path $PSScriptRoot "chorus-dev.bat") start $Resolved.portal_environment_file $Resolved.portal_address $Resolved.portal_settings_file if ($LASTEXITCODE -ne 0) { throw "Portal startup failed." } return $true } function Stop-ManagedProcess { param([ValidateSet("admin", "admin-ui")][string]$Name) $managed = Get-ManagedProcess $Name if ($null -eq $managed) { Write-Step "$Name is not managed or is already stopped." 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 Stop-Portal { if (-not (Test-PortalManaged)) { Write-Step "portal is not managed or is already stopped." return } & (Join-Path $PSScriptRoot "chorus-dev.bat") stop-portal if ($LASTEXITCODE -ne 0) { throw "Portal stop failed." } } function Show-ServiceStatus { param([ValidateSet("admin", "admin-ui")][string]$Name, $Endpoint) $managed = Get-ManagedProcess $Name if ($null -ne $managed) { Write-Step "${Name}: managed running (PID $($managed.State.pid), $($managed.State.address))" Write-Step "$Name logs: $($managed.State.stdout) and $($managed.State.stderr)" } elseif (Test-TcpPort $Endpoint.Host $Endpoint.Port) { Write-Step "${Name}: unmanaged listener on $($Endpoint.Address)" } else { Write-Step "${Name}: stopped ($($Endpoint.Address))" } } function Show-PortalStatus { param($Endpoint) if (Test-PortalManaged) { $state = Get-Content -LiteralPath $script:PortalStatePath -Raw | ConvertFrom-Json Write-Step "portal: managed running (PID $($state.pid), $($state.address))" Write-Step "portal logs: $($state.stdout) and $($state.stderr)" } elseif (Test-TcpPort $Endpoint.Host $Endpoint.Port) { Write-Step "portal: unmanaged listener on $($Endpoint.Address)" } else { Write-Step "portal: stopped ($($Endpoint.Address))" } } New-Item -ItemType Directory -Path $script:StateRoot -Force | Out-Null try { $resolved = Read-LocalServicesConfig $portalEndpoint = ConvertTo-Endpoint $resolved.portal_address $adminEndpoint = ConvertTo-Endpoint $resolved.admin_address $adminUIEndpoint = ConvertTo-Endpoint $resolved.admin_ui_address switch ($Command) { "status" { Show-PortalStatus $portalEndpoint Show-ServiceStatus "admin" $adminEndpoint Show-ServiceStatus "admin-ui" $adminUIEndpoint } "stop" { Stop-ManagedProcess "admin-ui" Stop-ManagedProcess "admin" Stop-Portal } "start" { $portalStarted = $false $adminStarted = $false try { $portalStarted = Start-Portal $resolved $adminStarted = Start-Admin $resolved $null = Start-AdminUI $resolved } catch { if ($adminStarted) { Stop-ManagedProcess "admin" } if ($portalStarted) { Stop-Portal } throw } Show-PortalStatus $portalEndpoint Show-ServiceStatus "admin" $adminEndpoint Show-ServiceStatus "admin-ui" $adminUIEndpoint } } } catch { [Console]::Error.WriteLine("[chorus-all] ERROR: " + $_.Exception.Message) exit 1 }