| 1 | # brtwin -access wrapper (cross-platform relay compatible with brt on macOS/Linux) |
| 2 | param( |
| 3 | [Parameter(Mandatory = $true, Position = 0)] |
| 4 | [string]$SubCmd, |
| 5 | [Parameter(ValueFromRemainingArguments = $true)] |
| 6 | [string[]]$Rest = @() |
| 7 | ) |
| 8 | |
| 9 | $ErrorActionPreference = "Continue" |
| 10 | $Rest = @($Rest) |
| 11 | |
| 12 | $WinRoot = Split-Path $PSScriptRoot -Parent |
| 13 | $Shared = Join-Path $PSScriptRoot "shared" |
| 14 | $AccessPy = Join-Path $Shared "access.py" |
| 15 | if (-not (Test-Path $AccessPy)) { |
| 16 | Write-Error "Missing $AccessPy — reinstall brtwin" |
| 17 | exit 1 |
| 18 | } |
| 19 | |
| 20 | function Write-BrtInfo($msg) { Write-Host "● $msg" -ForegroundColor Cyan } |
| 21 | function Write-BrtOk($msg) { Write-Host "✓ $msg" -ForegroundColor Green } |
| 22 | function Write-BrtWarn($msg) { Write-Host "! $msg" -ForegroundColor Yellow } |
| 23 | function Write-BrtErr($msg) { Write-Host "✗ $msg" -ForegroundColor Red; exit 1 } |
| 24 | |
| 25 | function Get-BrtUserHome { |
| 26 | $candidates = @( |
| 27 | $env:USERPROFILE, |
| 28 | $env:HOME, |
| 29 | [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) |
| 30 | ) |
| 31 | if ($env:LOCALAPPDATA) { |
| 32 | $candidates += (Split-Path $env:LOCALAPPDATA -Parent) |
| 33 | } |
| 34 | if ($env:USERNAME) { |
| 35 | $candidates += "C:\Users\$($env:USERNAME)" |
| 36 | } |
| 37 | $candidates += "C:\Users\Public" |
| 38 | |
| 39 | foreach ($candidate in $candidates) { |
| 40 | if ([string]::IsNullOrWhiteSpace($candidate)) { continue } |
| 41 | $candidate = $candidate.Trim().TrimEnd('\') |
| 42 | if (Test-Path -LiteralPath $candidate -PathType Container -ErrorAction SilentlyContinue) { |
| 43 | return $candidate |
| 44 | } |
| 45 | } |
| 46 | foreach ($candidate in $candidates) { |
| 47 | if (-not [string]::IsNullOrWhiteSpace($candidate)) { |
| 48 | return $candidate.Trim().TrimEnd('\') |
| 49 | } |
| 50 | } |
| 51 | return "C:\Users\Public" |
| 52 | } |
| 53 | |
| 54 | function Initialize-BrtAccessPaths { |
| 55 | $userHome = Get-BrtUserHome |
| 56 | $script:BrtStateDir = Join-Path $userHome ".brt" |
| 57 | $script:PidFile = Join-Path $script:BrtStateDir "access.pid" |
| 58 | $script:InfoFile = Join-Path $script:BrtStateDir "access.info" |
| 59 | if ($env:BRT_ACCESS_AUDIT_LOG -and $env:BRT_ACCESS_AUDIT_LOG.Trim()) { |
| 60 | $script:AuditLog = $env:BRT_ACCESS_AUDIT_LOG.Trim() |
| 61 | } else { |
| 62 | $script:AuditLog = Join-Path $script:BrtStateDir "access.audit.log" |
| 63 | } |
| 64 | if ($env:BRT_ACCESS_ADMIN_SOCK -and $env:BRT_ACCESS_ADMIN_SOCK.Trim()) { |
| 65 | $script:AdminSpec = $env:BRT_ACCESS_ADMIN_SOCK.Trim() |
| 66 | } else { |
| 67 | $script:AdminSpec = "tcp:127.0.0.1:8767" |
| 68 | } |
| 69 | if ([string]::IsNullOrWhiteSpace($script:BrtStateDir)) { |
| 70 | Write-BrtErr "Could not determine brt state directory" |
| 71 | } |
| 72 | New-Item -ItemType Directory -Force -Path $script:BrtStateDir | Out-Null |
| 73 | } |
| 74 | |
| 75 | function Get-PyExe() { |
| 76 | if (Get-Command python -ErrorAction SilentlyContinue) { return @("python") } |
| 77 | if (Get-Command py -ErrorAction SilentlyContinue) { return @("py", "-3") } |
| 78 | Write-BrtErr "Python 3 is required" |
| 79 | } |
| 80 | |
| 81 | function Invoke-AccessPy([string[]]$PyArgs) { |
| 82 | $py = Get-PyExe |
| 83 | $clean = @() |
| 84 | foreach ($arg in $PyArgs) { |
| 85 | if ($null -ne $arg -and -not [string]::IsNullOrWhiteSpace([string]$arg)) { |
| 86 | $clean += [string]$arg |
| 87 | } |
| 88 | } |
| 89 | & @py $AccessPy @clean |
| 90 | exit $LASTEXITCODE |
| 91 | } |
| 92 | |
| 93 | function Get-PortPids([int]$Port) { |
| 94 | $pids = [System.Collections.Generic.List[int]]::new() |
| 95 | try { |
| 96 | Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | |
| 97 | ForEach-Object { [void]$pids.Add($_.OwningProcess) } |
| 98 | } catch { |
| 99 | netstat -ano | Select-String ":$Port\s" | ForEach-Object { |
| 100 | if ($_ -match '\s(\d+)\s*$') { [void]$pids.Add([int]$Matches[1]) } |
| 101 | } |
| 102 | } |
| 103 | $pids | Select-Object -Unique |
| 104 | } |
| 105 | |
| 106 | function Stop-AccessForce([int]$Port) { |
| 107 | $all = [System.Collections.Generic.List[int]]::new() |
| 108 | if (Test-Path $PidFile) { |
| 109 | $p = (Get-Content $PidFile -Raw).Trim() |
| 110 | if ($p -match '^\d+$') { [void]$all.Add([int]$p) } |
| 111 | } |
| 112 | Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | |
| 113 | Where-Object { $_.CommandLine -and $_.CommandLine -match 'access\.py serve' } | |
| 114 | ForEach-Object { [void]$all.Add($_.ProcessId) } |
| 115 | Get-PortPids $Port | ForEach-Object { [void]$all.Add($_) } |
| 116 | $uniq = $all | Select-Object -Unique |
| 117 | foreach ($procId in $uniq) { |
| 118 | Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue |
| 119 | } |
| 120 | Remove-Item $PidFile, $InfoFile -Force -ErrorAction SilentlyContinue |
| 121 | if ($uniq.Count -gt 0) { Write-BrtOk "Force stopped $($uniq.Count) process(es), port free" } |
| 122 | else { Write-BrtWarn "No access server processes found" } |
| 123 | } |
| 124 | |
| 125 | function New-AccessToken() { |
| 126 | -join ((1..48) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) }) |
| 127 | } |
| 128 | |
| 129 | function Get-LocalEndpoint([int]$Port) { |
| 130 | $ip = $null |
| 131 | try { |
| 132 | $ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | |
| 133 | Where-Object { $_.IPAddress -notlike "127.*" -and $_.InterfaceOperationalStatus -eq "Up" } | |
| 134 | Select-Object -First 1).IPAddress |
| 135 | } catch { } |
| 136 | if (-not $ip) { |
| 137 | try { |
| 138 | $ip = ([System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName()) | |
| 139 | Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.IPAddressToString -notlike '127.*' } | |
| 140 | Select-Object -First 1).IPAddressToString |
| 141 | } catch { } |
| 142 | } |
| 143 | if (-not $ip) { $ip = "127.0.0.1" } |
| 144 | "${ip}:${Port}" |
| 145 | } |
| 146 | |
| 147 | function Wait-AccessAdmin([string]$AdminSpec, [int]$TimeoutSec = 15) { |
| 148 | if ($AdminSpec -notmatch '^tcp:([^:]+):(\d+)$') { return $false } |
| 149 | $hostName = $Matches[1] |
| 150 | $port = [int]$Matches[2] |
| 151 | $deadline = (Get-Date).AddSeconds($TimeoutSec) |
| 152 | while ((Get-Date) -lt $deadline) { |
| 153 | $client = $null |
| 154 | try { |
| 155 | $client = New-Object System.Net.Sockets.TcpClient |
| 156 | $connect = $client.BeginConnect($hostName, $port, $null, $null) |
| 157 | if ($connect.AsyncWaitHandle.WaitOne(300, $false)) { |
| 158 | $client.EndConnect($connect) |
| 159 | return $true |
| 160 | } |
| 161 | } catch { } |
| 162 | finally { |
| 163 | if ($client) { $client.Close() } |
| 164 | } |
| 165 | Start-Sleep -Milliseconds 200 |
| 166 | } |
| 167 | return $false |
| 168 | } |
| 169 | |
| 170 | Initialize-BrtAccessPaths |
| 171 | |
| 172 | switch ($SubCmd) { |
| 173 | "serve" { |
| 174 | $port = 8765 |
| 175 | for ($i = 0; $i -lt $Rest.Count; $i++) { |
| 176 | if ($Rest[$i] -in @("-p", "--port") -and ($i + 1) -lt $Rest.Count) { |
| 177 | $port = [int]$Rest[$i + 1] |
| 178 | } |
| 179 | } |
| 180 | $onPort = Get-PortPids $port |
| 181 | if ($onPort) { |
| 182 | Write-BrtWarn "Port $port in use (PID $($onPort -join ' ')) — freeing..." |
| 183 | Stop-AccessForce $port |
| 184 | } |
| 185 | $token = New-AccessToken |
| 186 | $logOut = Join-Path $BrtStateDir "access.stdout" |
| 187 | $logErr = Join-Path $BrtStateDir "access.log" |
| 188 | $py = Get-PyExe |
| 189 | $serveArgs = @( |
| 190 | $AccessPy, "serve", "--port", "$port", "--token", $token, |
| 191 | "--audit-log", $AuditLog, "--admin-sock", $AdminSpec |
| 192 | ) |
| 193 | if ($py.Count -eq 1) { |
| 194 | $proc = Start-Process -FilePath $py[0] -ArgumentList $serveArgs -PassThru -WindowStyle Hidden ` |
| 195 | -RedirectStandardOutput $logOut -RedirectStandardError $logErr |
| 196 | } else { |
| 197 | $proc = Start-Process -FilePath $py[0] -ArgumentList ($py[1..($py.Count - 1)] + $serveArgs) -PassThru -WindowStyle Hidden ` |
| 198 | -RedirectStandardOutput $logOut -RedirectStandardError $logErr |
| 199 | } |
| 200 | Start-Sleep -Milliseconds 500 |
| 201 | if ($proc.HasExited) { Write-BrtErr "Access server failed to start (see $logErr)" } |
| 202 | if (-not (Wait-AccessAdmin $AdminSpec)) { |
| 203 | Write-BrtWarn "Admin console not ready yet — dashboard may show offline briefly" |
| 204 | } |
| 205 | $endpoint = Get-LocalEndpoint $port |
| 206 | $connectCmd = "brtwin -access connect $endpoint $token" |
| 207 | try { |
| 208 | Set-Clipboard -Value $connectCmd |
| 209 | Write-BrtOk "Copied connect command to clipboard" |
| 210 | } catch { |
| 211 | Write-Host "● Connect: $connectCmd" -ForegroundColor Cyan |
| 212 | } |
| 213 | @" |
| 214 | token=$token |
| 215 | endpoint=$endpoint |
| 216 | port=$port |
| 217 | pid=$($proc.Id) |
| 218 | audit_log=$AuditLog |
| 219 | admin_sock=$AdminSpec |
| 220 | started=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")) |
| 221 | "@ | Set-Content $InfoFile |
| 222 | $proc.Id | Set-Content $PidFile |
| 223 | Invoke-AccessPy @( |
| 224 | "watch-logs", "--audit-log", $AuditLog, "--admin-sock", $AdminSpec, |
| 225 | "--interactive", "--server-pid", "$($proc.Id)", "--endpoint", $endpoint, |
| 226 | "--token", $token, "--owns-server" |
| 227 | ) |
| 228 | if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } |
| 229 | Remove-Item $PidFile, $InfoFile -Force -ErrorAction SilentlyContinue |
| 230 | } |
| 231 | "connect" { |
| 232 | if ($Rest.Count -lt 2) { Write-BrtErr "Usage: brtwin -access connect <host:port> <token>" } |
| 233 | Invoke-AccessPy @("connect", $Rest[0], $Rest[1]) |
| 234 | } |
| 235 | "clients" { Invoke-AccessPy @("clients", "--admin-sock", $AdminSpec) } |
| 236 | "leave" { Invoke-AccessPy @("leave") } |
| 237 | "disconnect" { |
| 238 | if (-not $Rest[0]) { |
| 239 | Invoke-AccessPy @("leave") |
| 240 | } else { |
| 241 | Invoke-AccessPy @("disconnect", $Rest[0], "--admin-sock", $AdminSpec) |
| 242 | } |
| 243 | } |
| 244 | "logs" { |
| 245 | $endpoint = ""; $token = ""; $serverPid = 0 |
| 246 | if (Test-Path $InfoFile) { |
| 247 | Get-Content $InfoFile | ForEach-Object { |
| 248 | if ($_ -match '^endpoint=(.+)$') { $endpoint = $Matches[1] } |
| 249 | if ($_ -match '^token=(.+)$') { $token = $Matches[1] } |
| 250 | } |
| 251 | } |
| 252 | if (Test-Path $PidFile) { |
| 253 | $p = (Get-Content $PidFile -Raw).Trim() |
| 254 | if (Get-Process -Id $p -ErrorAction SilentlyContinue) { $serverPid = [int]$p } |
| 255 | } |
| 256 | Invoke-AccessPy @( |
| 257 | "watch-logs", "--audit-log", $AuditLog, "--admin-sock", $AdminSpec, |
| 258 | "--interactive", "--server-pid", "$serverPid", "--endpoint", $endpoint, "--token", $token |
| 259 | ) |
| 260 | } |
| 261 | "clear-logs" { |
| 262 | Invoke-AccessPy @("clear-logs", "--audit-log", $AuditLog) |
| 263 | } |
| 264 | "force-stop" { Stop-AccessForce 8765 } |
| 265 | "stop" { |
| 266 | if (Test-Path $PidFile) { |
| 267 | $p = (Get-Content $PidFile -Raw).Trim() |
| 268 | Stop-Process -Id $p -Force -ErrorAction SilentlyContinue |
| 269 | Write-BrtOk "Stopped access server (PID $p)" |
| 270 | } else { Write-BrtWarn "No access server running" } |
| 271 | Remove-Item $PidFile, $InfoFile -Force -ErrorAction SilentlyContinue |
| 272 | } |
| 273 | "status" { |
| 274 | if (Test-Path $InfoFile) { |
| 275 | Get-Content $InfoFile |
| 276 | Invoke-AccessPy @("clients", "--admin-sock", $AdminSpec) |
| 277 | } else { Write-BrtWarn "No access server info found" } |
| 278 | } |
| 279 | default { Write-BrtErr "Unknown access command: $SubCmd" } |
| 280 | } |
| 281 | |