mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Добавлены безопасные pinned entrypoints, вывод фактических путей, идемпотентный Windows setup и WSL verification с настоящим HA subset и Linux capture. Issue: #557 User-Visible: no
179 lines
7.2 KiB
PowerShell
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
|
|
}
|