# install.ps1 - iria-monitor Windows installer (unified) # # Two ways to run it, auto-detected: # * Public / URL: irm https://ai.obsly.io/install.ps1 | iex # -> installs the published package from PyPI. # * Beta / bundle: powershell -ExecutionPolicy Bypass -File install.ps1 # -> if an iria_monitor-*.whl sits next to this script, # installs from that local wheel instead. Set-StrictMode -Version Latest $TelemetryUrl = "https://staging.monitor.iria.tech/api/otel/v1/logs" # rewritten per-env by the SaaS route $ServerUrl = "https://staging.monitor.iria.tech" # rewritten per-env by the SaaS route $PackageName = "iria-monitor" $BinaryName = "iria-monitor" $MinPython = [version]"3.10" $MaxPython = [version]"3.13.99" # When served by an environment, $ServerUrl is the URL it was downloaded from. # When run from disk (beta bundle, not served), the placeholder is untouched - # fall back to the Plexus default so the bundle flow keeps working. if ($ServerUrl -notmatch '^https?://') { $ServerUrl = "https://plexus-monitor.iria.tech" } # --- Helpers --------------------------------------------------------------- function Write-Step($msg) { Write-Host "=> $msg" -ForegroundColor Cyan } function Write-Ok($msg) { Write-Host " $msg" -ForegroundColor Green } function Write-Err($msg) { Write-Host " $msg" -ForegroundColor Red } function Get-OsVersion { try { [System.Environment]::OSVersion.Version.ToString() } catch { "unknown" } } function Invoke-Python { # Run python as: $PyExe $PyArgs . # E.g. PyExe=py, PyArgs=@("-3.13") -> `Invoke-Python -m pipx --version` # runs `py -3.13 -m pipx --version`. Native stderr is left alone so # StrictMode + a global Stop preference never wrap exit-0 output in a # NativeCommandError; callers gate on $LASTEXITCODE instead. $allArgs = $script:PyArgs + $args & $script:PyExe @allArgs } function Invoke-PythonCaptureUtf8 { param([string[]]$Arguments) # Windows PowerShell 5.1 decodes captured native stdout with the active # OEM code page. That corrupts UTF-8 pipx paths such as "Pruebas técnicas". # Let Python redirect the child process bytes and read the file explicitly # as UTF-8 instead. $outputPath = [IO.Path]::GetTempFileName() $captureScript = "import os, pathlib, subprocess, sys; env_copy = os.environ.copy(); env_copy['PYTHONIOENCODING'] = 'utf-8'; result = subprocess.run([sys.executable] + sys.argv[2:], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env_copy); pathlib.Path(sys.argv[1]).write_bytes(result.stdout); raise SystemExit(result.returncode)" try { Invoke-Python -c $captureScript $outputPath @Arguments if ($LASTEXITCODE -ne 0) { return $null } return [IO.File]::ReadAllText($outputPath, [Text.Encoding]::UTF8).Trim() } finally { Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue } } function Stop-RunningTray { # A live tray (pythonw from the iria-monitor venv) keeps its loaded # .pyd/.dll files locked. pipx then cannot delete the venv and the install # crashes with "[WinError 5] Access denied" on a Pillow .pyd in pipx\trash. # Terminating any running instance first makes install/upgrade reliable. try { $procs = Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.ExecutablePath -like "*pipx\venvs\iria-monitor*" -or ($_.CommandLine -match "claudedashboard" -and $_.CommandLine -match "tray" -and $_.CommandLine -match "run") } } catch { return } foreach ($p in $procs) { try { Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop } catch { } } if ($procs) { Write-Ok "Stopped $($procs.Count) running iria-monitor instance(s)" Start-Sleep -Milliseconds 500 # let Windows release the file handles } } function Send-Telemetry { param( [int]$ExitCode, [string]$Message, [string]$PythonVersion, [string]$PythonSource, [bool]$PipxPreinstalled, [string]$InstallKind, [string]$FailedStep ) $ctx = @{ os_version = Get-OsVersion python_source = $PythonSource pipx_preinstalled = $PipxPreinstalled extras = "core" } if ($FailedStep) { $ctx["failed_step"] = $FailedStep } $attrs = @( @{ key = "event.name"; value = @{ stringValue = "cli.install" } }, @{ key = "platform"; value = @{ stringValue = "windows" } }, @{ key = "python.version"; value = @{ stringValue = $PythonVersion } }, @{ key = "install.kind"; value = @{ stringValue = $InstallKind } }, @{ key = "exit.code"; value = @{ intValue = [string]$ExitCode } }, @{ key = "context.os_version"; value = @{ stringValue = $ctx["os_version"] } }, @{ key = "context.python_source"; value = @{ stringValue = $ctx["python_source"] } }, @{ key = "context.pipx_preinstalled"; value = @{ boolValue = $ctx["pipx_preinstalled"] } }, @{ key = "context.extras"; value = @{ stringValue = $ctx["extras"] } } ) if ($Message) { $attrs += @{ key = "event.message"; value = @{ stringValue = $Message } } } if ($FailedStep) { $attrs += @{ key = "context.failed_step"; value = @{ stringValue = $FailedStep } } } $payload = @{ resourceLogs = @(@{ resource = @{ attributes = @( @{ key = "service.name"; value = @{ stringValue = "iria-monitor-cli" } }, @{ key = "telemetry.sdk.language"; value = @{ stringValue = "powershell" } }, @{ key = "deployment.environment"; value = @{ stringValue = "cli" } } ) } scopeLogs = @(@{ scope = @{ name = "iria-monitor-cli.telemetry" } logRecords = @(@{ timeUnixNano = [string]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() * 1000000) severityText = $(if ($ExitCode -eq 0) { "INFO" } else { "ERROR" }) severityNumber = $(if ($ExitCode -eq 0) { 9 } else { 17 }) body = @{ stringValue = "cli.install" } attributes = $attrs }) }) }) } | ConvertTo-Json -Depth 8 try { Invoke-RestMethod -Uri $TelemetryUrl -Method Post ` -ContentType "application/json" -Body $payload ` -TimeoutSec 5 | Out-Null } catch { # telemetry is best-effort - never block install } } # --- Step 0: Decide install source (local wheel vs SaaS wheel vs PyPI) ----- # Priority: 1) local wheel next to script 2) download from SaaS 3) PyPI $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition $WheelPath = $null $InstallKind = "pipx" # 1) Check for a wheel bundled next to the script (offline/beta) if ($ScriptDir) { $wheels = Get-ChildItem -Path $ScriptDir -Filter "iria_monitor-*.whl" -ErrorAction SilentlyContinue if ($wheels) { $WheelPath = ($wheels | Select-Object -First 1).FullName $InstallKind = "wheel" } } # 2) No local wheel? Try downloading from the SaaS environment if (-not $WheelPath -and $ServerUrl -match '^https?://') { Write-Step "Downloading wheel from $ServerUrl..." $TmpWhl = Join-Path ([System.IO.Path]::GetTempPath()) "iria_monitor.whl" try { $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri "$ServerUrl/dist/iria-monitor.whl" -OutFile $TmpWhl -TimeoutSec 30 -ErrorAction Stop $WheelPath = $TmpWhl $InstallKind = "wheel-served" Write-Ok "Downloaded wheel from server" } catch { Write-Host " Wheel not available from server, falling back to PyPI" -ForegroundColor Yellow } } # --- Step 1: Find Python 3.10-3.13 ---------------------------------------- Write-Step "Looking for Python 3.10-3.13..." $PyExe = $null $PyArgs = @() $PythonVer = $null $PythonSource = "unknown" # Try the py launcher first (preferred on Windows) $pyLauncher = Get-Command py -ErrorAction SilentlyContinue if ($pyLauncher) { foreach ($minor in @(13, 12, 11, 10)) { try { $out = & py "-3.$minor" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')" 2>$null if ($LASTEXITCODE -eq 0 -and $out) { $ver = [version]$out.Trim() if ($ver -ge $MinPython -and $ver -le $MaxPython) { $PyExe = "py" $PyArgs = @("-3.$minor") $PythonVer = $out.Trim() $PythonSource = "py_launcher" break } } } catch { continue } } } # Fallback: python3 / python on PATH if (-not $PyExe) { foreach ($candidate in @("python3", "python")) { $cmd = Get-Command $candidate -ErrorAction SilentlyContinue if ($cmd) { try { $out = & $candidate -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')" 2>$null if ($LASTEXITCODE -eq 0 -and $out) { $ver = [version]$out.Trim() if ($ver -ge $MinPython -and $ver -le $MaxPython) { $PyExe = $candidate $PyArgs = @() $PythonVer = $out.Trim() $PythonSource = "path" break } } } catch { continue } } } } if (-not $PyExe) { Write-Err "No Python 3.10-3.13 found." Write-Err "Python 3.14+ is not supported yet (numpy/matplotlib won't build)." Write-Host "" Write-Host "Install Python 3.13 from: https://www.python.org/downloads/" -ForegroundColor Yellow Send-Telemetry -ExitCode 1 -Message "no compatible python" ` -PythonVersion "none" -PythonSource "none" ` -PipxPreinstalled $false -InstallKind $InstallKind -FailedStep "python_detection" exit 1 } Write-Ok "Found Python $PythonVer ($PythonSource)" # Resolve the real interpreter path - pipx --python needs a path, not "py -3.13". $oldPref = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $PythonPath = Invoke-Python -c "import sys; print(sys.executable)" 2>&1 $ErrorActionPreference = $oldPref $PythonPath = "$PythonPath".Trim() if (-not $PythonPath -or -not (Test-Path $PythonPath)) { $PythonPath = $PyExe } # --- Step 2: Install pipx if needed --------------------------------------- Write-Step "Checking for pipx..." $PipxPreinstalled = $false $oldPref = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $pipxCheck = Invoke-Python -m pipx --version 2>&1 $pipxExit = $LASTEXITCODE $ErrorActionPreference = $oldPref if ($pipxExit -eq 0) { $PipxPreinstalled = $true Write-Ok "pipx already installed ($("$pipxCheck".Trim()))" } else { Write-Step "Installing pipx..." $ErrorActionPreference = "SilentlyContinue" Invoke-Python -m pip install --user pipx 2>&1 | Out-Null $pipInstallExit = $LASTEXITCODE $ErrorActionPreference = $oldPref if ($pipInstallExit -ne 0) { Write-Err "Failed to install pipx." Write-Host "Try manually: $PythonPath -m pip install --user pipx" -ForegroundColor Yellow Send-Telemetry -ExitCode 2 -Message "pipx install failed" ` -PythonVersion $PythonVer -PythonSource $PythonSource ` -PipxPreinstalled $false -InstallKind $InstallKind -FailedStep "pipx_install" exit 1 } $ErrorActionPreference = "SilentlyContinue" Invoke-Python -m pipx --version 2>&1 | Out-Null $pipxVerifyExit = $LASTEXITCODE $ErrorActionPreference = $oldPref if ($pipxVerifyExit -ne 0) { Write-Err "pipx installed but not reachable via 'python -m pipx'." Send-Telemetry -ExitCode 2 -Message "pipx not reachable after install" ` -PythonVersion $PythonVer -PythonSource $PythonSource ` -PipxPreinstalled $false -InstallKind $InstallKind -FailedStep "pipx_verify" exit 1 } Write-Ok "pipx installed" } # --- Step 3: Install iria-monitor ----------------------------------------- if ($WheelPath) { Write-Step "Installing $BinaryName from $(Split-Path -Leaf $WheelPath)..." $installSpec = $WheelPath } else { Write-Step "Installing $PackageName from PyPI..." $installSpec = $PackageName } # Stop any live instance first so pipx can replace the venv (avoids WinError 5 # from a locked .pyd on a fresh install or an upgrade over a running tray). Stop-RunningTray $oldPref = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" $installOutput = Invoke-Python -m pipx install $installSpec --force --python $PythonPath 2>&1 $installExit = $LASTEXITCODE $ErrorActionPreference = $oldPref if ($installExit -ne 0) { Write-Err "Failed to install $BinaryName." Write-Host ($installOutput | Out-String) -ForegroundColor Yellow Send-Telemetry -ExitCode 3 -Message "package install failed" ` -PythonVersion $PythonVer -PythonSource $PythonSource ` -PipxPreinstalled $PipxPreinstalled -InstallKind $InstallKind -FailedStep "package_install" exit 1 } Write-Ok "$BinaryName installed" # --- Step 4: Ensure PATH -------------------------------------------------- Write-Step "Configuring PATH..." # pipx itself is installed in Python's user scripts directory, while apps # exposed by pipx live in PIPX_BIN_DIR (normally ~/.local/bin). Keep both on # this process PATH so the setup wizard can call pipx before a terminal restart. $ErrorActionPreference = "SilentlyContinue" $pipUserScripts = Invoke-PythonCaptureUtf8 @("-c", "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user'))") $pipUserScriptsExit = $LASTEXITCODE $pipxBinValue = Invoke-PythonCaptureUtf8 @("-m", "pipx", "environment", "--value", "PIPX_BIN_DIR") $pipxEnvironmentExit = $LASTEXITCODE $ErrorActionPreference = $oldPref $pipUserScripts = "$pipUserScripts".Trim() $pipxBinDir = "$pipxBinValue".Trim() if ($pipxEnvironmentExit -ne 0 -or -not $pipxBinDir) { Write-Err "Could not resolve pipx's application directory." Send-Telemetry -ExitCode 4 -Message "pipx bin directory unavailable" ` -PythonVersion $PythonVer -PythonSource $PythonSource ` -PipxPreinstalled $PipxPreinstalled -InstallKind $InstallKind -FailedStep "pipx_bin_dir" exit 1 } $currentPathParts = @() if ($pipUserScriptsExit -eq 0 -and $pipUserScripts) { $currentPathParts += $pipUserScripts } $currentPathParts += $pipxBinDir $currentPathParts += $env:PATH $env:PATH = $currentPathParts -join [IO.Path]::PathSeparator $ErrorActionPreference = "SilentlyContinue" Invoke-Python -m pipx ensurepath 2>&1 | Out-Null $ErrorActionPreference = $oldPref Write-Ok "PATH updated (restart your terminal to pick it up)" # --- Step 5: Verify ------------------------------------------------------- Write-Step "Verifying installation..." $binaryPath = Join-Path $pipxBinDir "$BinaryName.exe" if (-not (Test-Path -LiteralPath $binaryPath -PathType Leaf)) { Write-Err "$BinaryName was not created in pipx's application directory." Write-Host "Expected location: $binaryPath" -ForegroundColor Yellow Send-Telemetry -ExitCode 4 -Message "pipx entry point missing" ` -PythonVersion $PythonVer -PythonSource $PythonSource ` -PipxPreinstalled $PipxPreinstalled -InstallKind $InstallKind -FailedStep "binary_verify" exit 1 } $versionOut = & $binaryPath version 2>$null if ($LASTEXITCODE -eq 0) { Write-Ok "$BinaryName $($versionOut.Trim()) is ready" } else { Write-Err "$BinaryName entry point exists but could not run." Send-Telemetry -ExitCode 4 -Message "pipx entry point failed" ` -PythonVersion $PythonVer -PythonSource $PythonSource ` -PipxPreinstalled $PipxPreinstalled -InstallKind $InstallKind -FailedStep "binary_execute" exit 1 } # --- Step 6: Telemetry (success) ------------------------------------------ Send-Telemetry -ExitCode 0 -Message "ok" ` -PythonVersion $PythonVer -PythonSource $PythonSource ` -PipxPreinstalled $PipxPreinstalled -InstallKind $InstallKind -FailedStep "" # --- Step 7: Run the setup wizard ----------------------------------------- Write-Host "" Write-Host "Installation complete!" -ForegroundColor Green Write-Host "" Write-Host "Note:" -ForegroundColor Yellow Write-Host " AI code attribution (git hooks) requires Git for Windows," Write-Host " which includes Git Bash. Download from: https://git-scm.com/download/win" Write-Host "" # Continue straight into the setup wizard. We do NOT pre-write any config here: # the wizard itself records the server URL (passed via --server-url) and walks # through account linking, AI tracking, tray and proxy. $iria = $binaryPath $found = Test-Path -LiteralPath $binaryPath -PathType Leaf if (-not $found) { Write-Err "$BinaryName is not on PATH yet." Write-Host "Restart your terminal, then run: $BinaryName install" -ForegroundColor Yellow } elseif (-not [Environment]::UserInteractive) { Write-Host "Run the setup wizard when ready: $BinaryName install" -ForegroundColor Yellow } else { Write-Step "Launching setup wizard ($BinaryName install)..." & $iria install --server-url $ServerUrl }