# AiKey Windows installer wrapper. Generated by main-site server. # # Pipeline (3 steps, each step prints OK / FAIL with actionable hint): # [1/3] set telemetry context vars (channel / sid / token / lang / etc.) # [2/3] curl.exe -> GitHub release zip -> %TEMP% # [3/3] Expand-Archive -> run local-install.ps1 from inside # # Channel : indiehackers # Code : ih04 # Lang : zh # Edition : personal # Version : v1.0.0-rc.5.1 $ErrorActionPreference = 'Stop' $TOTAL = 3 function Write-Step { param($N, $Msg) Write-Host ""; Write-Host "[$N/$TOTAL] $Msg" -ForegroundColor Cyan } function Write-StepOk { param($Msg) Write-Host " OK: $Msg" -ForegroundColor Green } function Write-StepWarn { param($Msg) Write-Host " WARN: $Msg" -ForegroundColor Yellow } # Send-AikeyTelemetry — best-effort POST to /t. Mirror of the bash # wrapper's _aikey_send helper and the rc.5+ lib/telemetry.ps1's # Send-Telemetry. Errors are swallowed (telemetry must NEVER block # install). Caller can pass extra k=v pairs (error_code, prev_step, # curl_exit) as a hashtable. function Send-AikeyTelemetry { param( [Parameter(Mandatory=$true)][string]$EventName, [hashtable]$Extra ) try { $params = [ordered]@{ event = $EventName token = $env:AIKEY_TELEMETRY_TOKEN sid = $env:AIKEY_TELEMETRY_SID channel = $env:AIKEY_TELEMETRY_CHANNEL code = $env:AIKEY_TELEMETRY_CODE lang = $env:AIKEY_TELEMETRY_LANG edition = $env:AIKEY_TELEMETRY_EDITION } if ($env:AIKEY_TELEMETRY_VERSION) { $params.version = $env:AIKEY_TELEMETRY_VERSION } if ($Extra) { foreach ($k in $Extra.Keys) { $params[$k] = [string]$Extra[$k] } } $qs = ($params.GetEnumerator() | ForEach-Object { "$([uri]::EscapeDataString($_.Key))=$([uri]::EscapeDataString([string]$_.Value))" }) -join '&' $url = "$($env:AIKEY_TELEMETRY_BASE)?$qs" # curl.exe over Invoke-WebRequest: same Win 11 24H2 .NET # HttpClient regression mitigation as the actual installer # download below. if (Get-Command curl.exe -ErrorAction SilentlyContinue) { & curl.exe --ssl-no-revoke -fsS --max-time 3 -o NUL $url 2>$null } else { Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 3 -ErrorAction SilentlyContinue | Out-Null } } catch { # swallow — telemetry never blocks install } } function Exit-Failed { param( [int]$Step, [string]$Phase, [string]$Reason, [string]$Hint, [string]$ErrorCode = "INSTALL_WRAPPER_INSTALLER_FAIL", [string]$PrevStep = "" ) # Fire telemetry BEFORE printing the wall-of-red — if the user # closes the terminal in panic, at least the event survives. # ErrorCode must come from the canonical enum in # events/registry.go (UPPER_SNAKE_CASE). $extra = @{ error_code = $ErrorCode } if ($PrevStep) { $extra.prev_step = $PrevStep } # Ship $Reason (the human-readable exception/error text) as # error_msg_b64 — base64 lets us carry the .NET Exception.Message # verbatim through a GET query string without worrying about # control chars / UTF-8 / URL reserveds. Cap at 500 raw chars, # same as bash wrapper; server further caps at 2KB after decode. if ($Reason) { $raw = if ($Reason.Length -gt 500) { $Reason.Substring(0, 500) } else { $Reason } $extra.error_msg_b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($raw)) } Send-AikeyTelemetry -EventName "site.install.failed" -Extra $extra Write-Host "" Write-Host ("=" * 64) -ForegroundColor Red Write-Host " INSTALL FAILED (step $Step/$TOTAL)" -ForegroundColor Red Write-Host ("=" * 64) -ForegroundColor Red Write-Host " 阶段 / Phase : $Phase" Write-Host " 原因 / Reason : $Reason" Write-Host " 建议 / Hint : $Hint" Write-Host ("=" * 64) exit 1 } # ─── [1/3] Set telemetry context ────────────────────────────── Write-Step 1 "设置 telemetry 上下文 (channel / sid / token)..." $env:AIKEY_TELEMETRY_TOKEN = 'eyJzaWQiOiI1ZjEwZGNiYi01NDMxLTQ3MjMtYTljMS05YWVjNWVmYWZmNjkiLCJpYXQiOjE3ODAxNTk1MjYsImV4cCI6MTc4MDI0NTkyNiwic2NvcGUiOiJpbnN0YWxsIiwiY29kZSI6ImloMDQiLCJsYW5nIjoiemgiLCJlZGl0aW9uIjoicGVyc29uYWwifQ.NhEfbyRVfJ3cTOIIF-MoxK_pV_ndwzWnFO2PogBXBGs' $env:AIKEY_TELEMETRY_SID = '5f10dcbb-5431-4723-a9c1-9aec5efaff69' $env:AIKEY_TELEMETRY_CHANNEL = 'indiehackers' $env:AIKEY_TELEMETRY_CODE = 'ih04' $env:AIKEY_TELEMETRY_LANG = 'zh' $env:AIKEY_TELEMETRY_EDITION = 'personal' $env:AIKEY_TELEMETRY_BASE = 'https://aikeylabs.com/t' $env:AIKEY_TELEMETRY_VERSION = 'v1.0.0-rc.5.1' Write-StepOk "channel=$($env:AIKEY_TELEMETRY_CHANNEL) lang=$($env:AIKEY_TELEMETRY_LANG) version=$($env:AIKEY_TELEMETRY_VERSION)" # Fire wrapper_executed BEFORE we hit GitHub. Mirror of the bash # wrapper's _aikey_send call. See events/registry.go for the full # rationale (disambiguate user-curled-to-inspect from # wrapper-ran-but-github-blocked). Send-AikeyTelemetry -EventName "site.install.wrapper_executed" # ─── [2/3] Download installer ZIP from GitHub ───────────────── # Why curl.exe-first: Win11 24H2 broke .NET HttpClient for many Let's # Encrypt-served endpoints; curl.exe calls Schannel SSPI directly, # bypasses the broken .NET layer, and natively honors HTTP_PROXY env # vars (Clash / V2Ray users route transparently). # Why --ssl-no-revoke: CN ISP can't reach Let's Encrypt / DigiCert # OCSP responders without VPN → Schannel revocation check fails with # CRYPT_E_NO_REVOCATION_CHECK. Disabling the check is the only way # for CN-without-VPN Windows users to install. $InstallerURL = 'https://github.com/aikeylabs/launch/releases/download/v1.0.0-rc.5.1/aikey-installer-windows_1.0.0-rc.5.1.zip' $TempBase = Join-Path $env:TEMP "aikey-installer-$([guid]::NewGuid())" $TempZip = "$TempBase.zip" $TempDir = "$TempBase" Write-Step 2 "下载 installer 包 (curl.exe -> $TempZip)..." $haveCurl = $null -ne (Get-Command curl.exe -ErrorAction SilentlyContinue) $fetched = $false if ($haveCurl) { & curl.exe --ssl-no-revoke -fsSL -o "$TempZip" "$InstallerURL" if ($LASTEXITCODE -eq 0 -and (Test-Path $TempZip) -and (Get-Item $TempZip).Length -gt 0) { $fetched = $true Write-StepOk "via curl.exe ($((Get-Item $TempZip).Length) bytes)" } else { Write-StepWarn "curl.exe exit $LASTEXITCODE; falling back to Invoke-WebRequest..." } } if (-not $fetched) { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $p = $env:HTTPS_PROXY, $env:HTTP_PROXY, $env:https_proxy, $env:http_proxy | Where-Object { $_ } | Select-Object -First 1 if ($p) { [Net.WebRequest]::DefaultWebProxy = New-Object Net.WebProxy($p) } [Net.WebRequest]::DefaultWebProxy.Credentials = [Net.CredentialCache]::DefaultCredentials Invoke-WebRequest -Uri $InstallerURL -UseBasicParsing -OutFile $TempZip -TimeoutSec 60 Write-StepOk "via Invoke-WebRequest fallback ($((Get-Item $TempZip).Length) bytes)" } catch { # ErrorCode categorization: we can't reliably get a curl-exit- # equivalent from .NET HttpClient exceptions on PowerShell, so # we map by Exception type name. The catch-all # INSTALL_WRAPPER_INSTALLER_FAIL lets dashboards still bucket # this layer separately from local-install.ps1 failures # (prev_step disambiguates). $exMsg = $_.Exception.Message $ec = "INSTALL_WRAPPER_INSTALLER_FAIL" if ($exMsg -match "name not known|name or service not known|DNS") { $ec = "INSTALL_NETWORK_DNS" } elseif ($exMsg -match "timed?\s?out|timeout|operation has timed out") { $ec = "INSTALL_NETWORK_TIMEOUT" } elseif ($exMsg -match "TLS|SSL|cert|trust|chain|handshake") { $ec = "INSTALL_NETWORK_TLS" } elseif ($exMsg -match "404|not found") { $ec = "INSTALL_WRAPPER_INSTALLER_NOT_FOUND" } Exit-Failed 2 "下载 installer (download installer)" ` "URL=$InstallerURL 错误=$exMsg" ` "CN 用户:GitHub 可能被 GFW 屏蔽,请挂上 VPN / Clash 后重试。若已挂代理仍失败,请检查代理后端是否能到 github.com。" ` -ErrorCode $ec -PrevStep "wrapper_installer_download" } } # ─── [3/3] Extract + run installer ──────────────────────────── Write-Step 3 "解压并运行 local-install.ps1..." try { New-Item -ItemType Directory -Force -Path $TempDir | Out-Null Expand-Archive -Path $TempZip -DestinationPath $TempDir -Force $InstallerScript = Join-Path $TempDir "local-install.ps1" if (-not (Test-Path $InstallerScript)) { # zip layout may have a top-level dir; descend one level $subdir = Get-ChildItem -Path $TempDir -Directory | Select-Object -First 1 if ($subdir) { $candidate = Join-Path $subdir.FullName "local-install.ps1" if (Test-Path $candidate) { $InstallerScript = $candidate } } } if (-not (Test-Path $InstallerScript)) { Exit-Failed 3 "定位 local-install.ps1 (locate installer)" ` "zip 内未找到 local-install.ps1。已解压到 $TempDir,内容: $(Get-ChildItem -Path $TempDir -Recurse -File | Select-Object -ExpandProperty Name -First 10 | Join-String -Separator ', ')" ` "release 包结构可能变了 → 请上报到 https://github.com/aikeylabs/launch/issues" ` -ErrorCode "INSTALL_WRAPPER_INSTALLER_NOT_FOUND" -PrevStep "wrapper_zip_extract" } Write-StepOk "找到 $InstallerScript" Write-Host "" Write-Host ("-" * 64) -ForegroundColor DarkGray Write-Host " 转交给 local-install.ps1 (后续输出由它打印)" -ForegroundColor DarkGray Write-Host ("-" * 64) -ForegroundColor DarkGray # Forward our resolved version into the installer's -Version param. # Without this, local-install.ps1 calls /releases/latest itself and # re-resolves to whatever GitHub considers "latest" — which IGNORES # the ?version= override the wrapper just used. With this, the # wrapper-zip's version and the installed binary's version match. # # Why direct call (not @args splat): PS array-splat `& script @arr` # passes the elements as POSITIONAL args, so `@('-Version','v1.0.0')` # binds position 0 = "-Version" (literal) and position 1 = "v1.0.0" # to whatever the second positional param is (InstallDir in our # case). Direct named `-Version $value` invocation guarantees named # binding. if ($env:AIKEY_TELEMETRY_VERSION) { & $InstallerScript -Version $env:AIKEY_TELEMETRY_VERSION } else { & $InstallerScript } } catch { # If we get here, local-install.ps1 itself crashed (vs gracefully # exiting with a status code). That usually means the inner # GitHub download from local-install.ps1 failed — local-install # has its own Send-Telemetry on its Install-Fail path, but this # wrapper-side catch is the safety net when its trap didn't fire # (e.g. .ps1 had a parse error before it could install the trap). Exit-Failed 3 "运行 installer (run installer)" $_.Exception.Message ` "若 local-install.ps1 内部又拉 GitHub 失败,通常是 GFW;挂 VPN / Clash 再试。详细错误见上面的 Exception 输出。" ` -ErrorCode "INSTALL_WRAPPER_INSTALLER_FAIL" -PrevStep "wrapper_installer_invoke" } finally { # Cleanup. Keep $TempDir on failure for post-mortem. if ($LASTEXITCODE -eq 0) { Remove-Item -Force -ErrorAction SilentlyContinue $TempZip Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $TempDir } }