Files
houseplan-card/scripts/windows-toolchain.ps1
Sergey Matyuninandclaude[bot] c1c3743f5d infra: закрепить локальный toolchain Windows и WSL (#557)
Добавлены безопасные pinned entrypoints, вывод фактических путей, идемпотентный Windows setup и WSL verification с настоящим HA subset и Linux capture.

Issue: #557
User-Visible: no
2026-09-13 06:05:45 +00:00

179 lines
7.2 KiB
PowerShell

#!/usr/bin/env pwsh
# Reproducible Windows entrypoint for House Plan's CI-compatible local tools (#557).
# Nothing is added to the persistent PATH and no existing venv is removed.
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[ValidateSet('setup', 'check', 'npm', 'node', 'python', 'playwright')]
[string]$Action = 'check',
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$Arguments = @(),
[string]$ToolRoot = (Join-Path $env:LOCALAPPDATA 'houseplan-toolchain'),
[string]$VenvPath = '.venv-ci'
)
$ErrorActionPreference = 'Stop'
$RepoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$NodeMajor = (Get-Content -LiteralPath (Join-Path $RepoRoot '.nvmrc') -Raw).Trim()
$PythonPin = (Get-Content -LiteralPath (Join-Path $RepoRoot '.python-version') -Raw).Trim()
$ResolvedToolRoot = [IO.Path]::GetFullPath($ToolRoot)
$ResolvedVenv = if ([IO.Path]::IsPathRooted($VenvPath)) {
[IO.Path]::GetFullPath($VenvPath)
} else {
[IO.Path]::GetFullPath((Join-Path $RepoRoot $VenvPath))
}
function Get-InstalledNode {
if (-not (Test-Path -LiteralPath $ResolvedToolRoot -PathType Container)) { return $null }
$candidates = Get-ChildItem -LiteralPath $ResolvedToolRoot -Directory | ForEach-Object {
if ($_.Name -match "^node-v($NodeMajor[.]\d+[.]\d+)-win-x64$" -and
(Test-Path -LiteralPath (Join-Path $_.FullName 'node.exe') -PathType Leaf)) {
[pscustomobject]@{ Version = [version]$Matches[1]; Directory = $_.FullName }
}
}
return $candidates | Sort-Object Version -Descending | Select-Object -First 1
}
function Install-PortableNode {
New-Item -ItemType Directory -Path $ResolvedToolRoot -Force | Out-Null
$releases = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json'
$release = $releases | Where-Object {
$_.version -match "^v$NodeMajor[.]" -and $_.files -contains 'win-x64-zip'
} | Select-Object -First 1
if (-not $release) { throw "Node $NodeMajor win-x64-zip was not found in the official release index" }
$archiveName = "node-$($release.version)-win-x64.zip"
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("houseplan-node-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
try {
$archive = Join-Path $temporaryRoot $archiveName
$baseUri = "https://nodejs.org/dist/$($release.version)"
Invoke-WebRequest -Uri "$baseUri/$archiveName" -OutFile $archive
$sums = (Invoke-WebRequest -Uri "$baseUri/SHASUMS256.txt").Content
$sumLine = $sums -split "`n" | Where-Object {
$_ -match ("\s" + [regex]::Escape($archiveName) + "\s*$")
} | Select-Object -First 1
if (-not $sumLine) { throw "SHA-256 for $archiveName was not found" }
$expected = ($sumLine.Trim() -split '\s+')[0].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw "SHA-256 mismatch for $archiveName" }
$expandedRoot = Join-Path $temporaryRoot 'expanded'
Expand-Archive -LiteralPath $archive -DestinationPath $expandedRoot
$expandedNode = Join-Path $expandedRoot "node-$($release.version)-win-x64"
$destination = Join-Path $ResolvedToolRoot "node-$($release.version)-win-x64"
if (-not (Test-Path -LiteralPath $destination)) {
Move-Item -LiteralPath $expandedNode -Destination $destination
}
Write-Host "Node $($release.version) installed at $destination"
} finally {
$safeTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$resolvedTemporary = [IO.Path]::GetFullPath($temporaryRoot)
if ($resolvedTemporary.StartsWith($safeTemp, [StringComparison]::OrdinalIgnoreCase)) {
Remove-Item -LiteralPath $resolvedTemporary -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
function Resolve-Runtimes([bool]$AllowInstall) {
$node = Get-InstalledNode
if (-not $node -and $AllowInstall) {
Install-PortableNode
$node = Get-InstalledNode
}
if (-not $node) {
throw "Portable Node $NodeMajor is absent. Run: .\scripts\windows-toolchain.ps1 setup"
}
$nodeExe = Join-Path $node.Directory 'node.exe'
$npmCmd = Join-Path $node.Directory 'npm.cmd'
$npxCmd = Join-Path $node.Directory 'npx.cmd'
$pythonExe = Join-Path $ResolvedVenv 'Scripts\python.exe'
$uv = $null
if ($AllowInstall) {
$uv = (Get-Command uv -ErrorAction SilentlyContinue).Source
if (-not $uv) {
throw 'uv is required. Install it once with: winget install --id astral-sh.uv --source winget'
}
& $uv python install $PythonPin
if ($LASTEXITCODE -ne 0) { throw "uv could not install Python $PythonPin" }
if (-not (Test-Path -LiteralPath $pythonExe)) {
& $uv venv --python $PythonPin $ResolvedVenv
if ($LASTEXITCODE -ne 0) { throw "uv could not create $ResolvedVenv" }
}
}
if (-not (Test-Path -LiteralPath $pythonExe)) {
throw "Pinned venv is absent. Run: .\scripts\windows-toolchain.ps1 setup"
}
$pythonVersion = (& $pythonExe -c 'import sys; print(".".join(map(str, sys.version_info[:2])))').Trim()
if ($pythonVersion -ne $PythonPin) {
throw "$ResolvedVenv uses Python $pythonVersion, expected $PythonPin. It was not removed; choose another -VenvPath."
}
if ($AllowInstall) {
$voluptuous = (Select-String -LiteralPath (Join-Path $RepoRoot 'tests_backend\requirements.txt') `
-Pattern '^voluptuous==\S+$').Line
& $uv pip install --python $pythonExe pytest pytest-asyncio $voluptuous
if ($LASTEXITCODE -ne 0) { throw 'uv could not install the native-Windows pure-test dependencies' }
}
return [pscustomobject]@{
NodeDirectory = $node.Directory
Node = $nodeExe
Npm = $npmCmd
Npx = $npxCmd
Python = $pythonExe
}
}
function Invoke-Check($runtime) {
$oldPath = $env:PATH
try {
$env:PATH = "$($runtime.NodeDirectory);$oldPath"
& $runtime.Node (Join-Path $RepoRoot 'scripts\toolchain-pins.mjs') --check "--python=$($runtime.Python)"
if ($LASTEXITCODE -ne 0) { throw 'Local toolchain does not match the CI pins' }
} finally {
$env:PATH = $oldPath
}
}
function Normalized-Arguments {
if ($Arguments.Count -gt 0 -and $Arguments[0] -eq '--') {
if ($Arguments.Count -eq 1) { return @() }
return $Arguments[1..($Arguments.Count - 1)]
}
return $Arguments
}
Push-Location $RepoRoot
try {
$timer = [Diagnostics.Stopwatch]::StartNew()
$runtime = Resolve-Runtimes ($Action -eq 'setup')
$oldPath = $env:PATH
$env:PATH = "$($runtime.NodeDirectory);$oldPath"
try {
if ($Action -eq 'setup') {
& $runtime.Npm ci --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw 'npm ci failed' }
& $runtime.Npx playwright install chromium
if ($LASTEXITCODE -ne 0) { throw 'Playwright Chromium installation failed' }
Invoke-Check $runtime
} elseif ($Action -eq 'check') {
Invoke-Check $runtime
} else {
$forward = @(Normalized-Arguments)
if ($Action -eq 'npm') { & $runtime.Npm @forward }
elseif ($Action -eq 'node') { & $runtime.Node @forward }
elseif ($Action -eq 'python') { & $runtime.Python @forward }
else { & $runtime.Npx playwright @forward }
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
} finally {
$env:PATH = $oldPath
}
$timer.Stop()
Write-Host ("House Plan {0}: {1:n1}s" -f $Action, $timer.Elapsed.TotalSeconds)
} finally {
Pop-Location
}