[CmdletBinding()] param( [Parameter(Position = 0)] [ValidateSet("install", "run-portal", "run-admin", "run-admin-ui")] [string]$Command = "install", [string]$SupervisorRoot = "D:\supervisor", [string]$ConfigFile ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $script:RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) if ([string]::IsNullOrWhiteSpace($ConfigFile)) { $ConfigFile = Join-Path $script:RepoRoot "config\local-services.yml" } $ConfigFile = [IO.Path]::GetFullPath($ConfigFile) $SupervisorRoot = [IO.Path]::GetFullPath($SupervisorRoot) $script:InstallRoot = Join-Path $SupervisorRoot "chorus" $script:BinRoot = Join-Path $script:InstallRoot "bin" $script:ProgramsRoot = Join-Path $SupervisorRoot "programs" $script:LogsRoot = Join-Path $SupervisorRoot "logs" $script:Resolver = Join-Path $script:BinRoot "chorus-local-config.exe" function Write-Step { param([string]$Message) Write-Host "[chorus-supervisor] $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 Get-ResolvedConfig { if (-not (Test-Path -LiteralPath $script:Resolver -PathType Leaf)) { throw "Supervisor config resolver is missing. Run scripts\install-supervisor.bat first." } $output = & $script:Resolver -config $ConfigFile if ($LASTEXITCODE -ne 0) { throw "Local services configuration validation failed." } 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]@{ Host = $Matches.host; Port = [int]$Matches.port } } function Invoke-CheckedBuild { param([string]$Go, [string[]]$Arguments, [string]$Output, [string]$Name) & $Go @Arguments if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $Output -PathType Leaf)) { throw "Failed to build $Name." } } function Quote-SupervisorArgument { param([string]$Value) if ($Value.Contains('"')) { throw "Supervisor paths must not contain quotation marks." } return '"' + $Value + '"' } function ConvertTo-SupervisorPath { param([string]$Value) return $Value.Replace('\', '/') } function Install-ChorusSupervisor { $supervisor = Join-Path $SupervisorRoot "supervisord.exe" $supervisorConfig = Join-Path $SupervisorRoot "supervisord.conf" if (-not (Test-Path -LiteralPath $supervisor -PathType Leaf)) { throw "supervisord.exe was not found in $SupervisorRoot." } if (-not (Test-Path -LiteralPath $supervisorConfig -PathType Leaf)) { throw "supervisord.conf was not found in $SupervisorRoot." } foreach ($directory in @($script:InstallRoot, $script:BinRoot, $script:ProgramsRoot, $script:LogsRoot)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $go = Get-GoCommand $corepack = Get-Command corepack.cmd -ErrorAction SilentlyContinue if ($null -eq $corepack) { throw "Corepack was not found. Install the locked Node toolchain before installing Chorus." } $portal = Join-Path $script:BinRoot "chorus-portal.exe" $admin = Join-Path $script:BinRoot "chorus-admin.exe" Write-Step "Building Portal, Admin API, and configuration resolver..." Push-Location $script:RepoRoot try { Invoke-CheckedBuild $go @("build", "-o", $portal, "./portal") $portal "Portal" Invoke-CheckedBuild $go @("build", "-o", $script:Resolver, "./cmd/chorus-local-config") $script:Resolver "configuration resolver" Invoke-CheckedBuild $go @("-C", "admin", "build", "-o", $admin, ".") $admin "Admin API" } finally { Pop-Location } $pnpmProxy = Join-Path $script:BinRoot "pnpm.cmd" $pnpmProxyBody = '@"' + $corepack.Source + '" pnpm %*' + [Environment]::NewLine [IO.File]::WriteAllText($pnpmProxy, $pnpmProxyBody, [Text.ASCIIEncoding]::new()) $resolved = Get-ResolvedConfig $portalEndpoint = ConvertTo-Endpoint $resolved.portal_address $adminEndpoint = ConvertTo-Endpoint $resolved.admin_address $adminUIEndpoint = ConvertTo-Endpoint $resolved.admin_ui_address $runner = ConvertTo-SupervisorPath ([IO.Path]::GetFullPath($PSCommandPath)) $supervisorRootArgument = ConvertTo-SupervisorPath $SupervisorRoot $configFileArgument = ConvertTo-SupervisorPath $ConfigFile $repoRootArgument = ConvertTo-SupervisorPath $script:RepoRoot $logsRootArgument = ConvertTo-SupervisorPath $script:LogsRoot $commandPrefix = "pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File $(Quote-SupervisorArgument $runner)" $commonArguments = "-SupervisorRoot $(Quote-SupervisorArgument $supervisorRootArgument) -ConfigFile $(Quote-SupervisorArgument $configFileArgument)" $programConfig = @" [program:chorus-user] command=$commandPrefix run-portal $commonArguments directory=$repoRootArgument autostart=true autorestart=true startsecs=3 startretries=3 stopwaitsecs=20 stopasgroup=true killasgroup=true redirect_stderr=true stdout_logfile=$logsRootArgument/chorus-user.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=5 [program:chorus-admin-api] command=$commandPrefix run-admin $commonArguments directory=$repoRootArgument/admin autostart=true autorestart=true startsecs=3 startretries=3 stopwaitsecs=20 stopasgroup=true killasgroup=true redirect_stderr=true stdout_logfile=$logsRootArgument/chorus-admin-api.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=5 [program:chorus-admin-ui] command=$commandPrefix run-admin-ui $commonArguments directory=$repoRootArgument/admin-ui autostart=true autorestart=true startsecs=3 startretries=3 stopwaitsecs=20 stopasgroup=true killasgroup=true redirect_stderr=true stdout_logfile=$logsRootArgument/chorus-admin-ui.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=5 "@ $programPath = Join-Path $script:ProgramsRoot "chorus.conf" [IO.File]::WriteAllText($programPath, $programConfig, [Text.UTF8Encoding]::new($false)) $supervisorBody = [IO.File]::ReadAllText($supervisorConfig) if ($supervisorBody -notmatch '(?m)^\s*\[include\]\s*$') { $include = "`r`n[include]`r`nfiles=%(here)s/programs/*.conf`r`n" [IO.File]::AppendAllText($supervisorConfig, $include, [Text.UTF8Encoding]::new($false)) } elseif ($supervisorBody -notmatch '(?m)^\s*files\s*=.*programs[/\\]\*\.conf') { throw "The existing [include] section does not load programs/*.conf; update it before installing Chorus." } Write-Step "Installed three programs:" Write-Step "chorus-user -> $($resolved.portal_address)" Write-Step "chorus-admin-api -> $($resolved.admin_address)" Write-Step "chorus-admin-ui -> $($resolved.admin_ui_address)" Write-Step "Reload Supervisor with: D:\supervisor\supervisord.exe ctl /c D:\supervisor\supervisord.conf reload" } function Run-Portal { $resolved = Get-ResolvedConfig . $resolved.portal_environment_file if ([string]::IsNullOrWhiteSpace($env:CHORUS_DSN)) { throw "CHORUS_DSN is required in the portal environment file." } $env:CHORUS_LISTEN_ADDRESS = $resolved.portal_address $executable = Join-Path $script:BinRoot "chorus-portal.exe" & $executable --config $resolved.portal_settings_file exit $LASTEXITCODE } function Run-Admin { $resolved = Get-ResolvedConfig $executable = Join-Path $script:BinRoot "chorus-admin.exe" & $executable server --config $resolved.admin_settings_file exit $LASTEXITCODE } function Run-AdminUI { $resolved = Get-ResolvedConfig $endpoint = ConvertTo-Endpoint $resolved.admin_ui_address $adminUIRoot = Join-Path $script:RepoRoot "admin-ui" $entry = Join-Path $adminUIRoot "node_modules\@vue\cli-service\bin\vue-cli-service.js" if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) { throw "Admin UI dependencies are missing." } $pnpmShim = Join-Path $script:BinRoot "pnpm.cmd" if (-not (Test-Path -LiteralPath $pnpmShim -PathType Leaf)) { throw "The installed pnpm proxy is missing. Run scripts\install-supervisor.bat again." } $pnpmDirectory = Split-Path -Parent $pnpmShim if (($env:Path -split ';') -notcontains $pnpmDirectory) { $env:Path = $pnpmDirectory + ';' + $env:Path } $env:VUE_APP_BASE_API = $resolved.admin_ui_api_base & $pnpmShim --dir $adminUIRoot exec vue-cli-service serve --host $endpoint.Host --port $endpoint.Port exit $LASTEXITCODE } switch ($Command) { "install" { Install-ChorusSupervisor } "run-portal" { Run-Portal } "run-admin" { Run-Admin } "run-admin-ui" { Run-AdminUI } }