From ac0525ffeea36972ed505596680ffcfc344261f4 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 18:10:57 +0800 Subject: [PATCH 1/7] fix(installer): detect Windows architecture without RuntimeInformation --- install.ps1 | 14 +++++++--- scripts/install-windows.test.ps1 | 46 +++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index 5dd7889b..d7b95571 100644 --- a/install.ps1 +++ b/install.ps1 @@ -3,7 +3,7 @@ install.ps1 — install the bsk CLI on Windows from GitHub Releases. .DESCRIPTION -Downloads the latest (or pinned) bsk release for Windows x64, +Downloads the latest (or pinned) bsk release for Windows x64 or ARM64, extracts bsk.exe to a user-local directory, and adds it to PATH. Usage: @@ -38,11 +38,17 @@ function Write-Die { # ── Platform / architecture detection ───────────────────────────────────────── function Get-PlatformTriple { - $arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture + # RuntimeInformation.ProcessArchitecture can be unavailable in Windows PowerShell 5.1. + # WOW64 exposes the native architecture separately from the 32-bit process. + $arch = $env:PROCESSOR_ARCHITEW6432 + if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } + if (-not $arch) { + Write-Die "could not detect Windows architecture: PROCESSOR_ARCHITEW6432 and PROCESSOR_ARCHITECTURE are empty" + } switch ($arch) { - "X64" { $archId = "x64" } - "Arm64" { $archId = "arm64" } + "AMD64" { $archId = "x64" } + "ARM64" { $archId = "arm64" } default { Write-Die "unsupported architecture: $arch (x64 and ARM64 only)" } } diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index 4dbe589c..9c9cd877 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -1,5 +1,8 @@ #Requires -Version 5.1 -param([string]$BskPath = (Join-Path $PSScriptRoot "../target/debug/bsk.exe")) +param( + [string]$BskPath = (Join-Path $PSScriptRoot "../target/debug/bsk.exe"), + [switch]$ArchitectureOnly +) $ErrorActionPreference = "Stop" # Load definitions, never Main: no downloads or changes to the real user PATH. @@ -27,6 +30,47 @@ function Assert-Fails([scriptblock]$Action) { if (-not $failed) { throw "expected failure" } } +# Isolate the fatal-error stub and restore the real process environment. +& { + function Write-Die([string]$Message) { throw $Message } + $oldProcessArch = $env:PROCESSOR_ARCHITECTURE + $oldNativeArch = $env:PROCESSOR_ARCHITEW6432 + try { + $cases = @( + @{ Process = 'AMD64'; Native = $null; Arch = 'x64'; Triple = 'x86_64-pc-windows-msvc' } + @{ Process = 'ARM64'; Native = $null; Arch = 'arm64'; Triple = 'aarch64-pc-windows-msvc' } + @{ Process = 'amd64'; Native = $null; Arch = 'x64'; Triple = 'x86_64-pc-windows-msvc' } + @{ Process = 'x86'; Native = 'AMD64'; Arch = 'x64'; Triple = 'x86_64-pc-windows-msvc' } + @{ Process = 'x86'; Native = 'ARM64'; Arch = 'arm64'; Triple = 'aarch64-pc-windows-msvc' } + @{ Process = $null; Native = 'AMD64'; Arch = 'x64'; Triple = 'x86_64-pc-windows-msvc' } + @{ Process = 'x86'; Native = $null; Error = 'unsupported architecture: x86 (x64 and ARM64 only)' } + @{ Process = 'AMD64'; Native = 'IA64'; Error = 'unsupported architecture: IA64 (x64 and ARM64 only)' } + @{ Process = $null; Native = $null; Error = 'could not detect Windows architecture: PROCESSOR_ARCHITEW6432 and PROCESSOR_ARCHITECTURE are empty' } + ) + foreach ($case in $cases) { + $env:PROCESSOR_ARCHITECTURE = $case.Process + $env:PROCESSOR_ARCHITEW6432 = $case.Native + if ($case.Error) { + $message = $null + try { Get-PlatformTriple | Out-Null } catch { $message = $_.Exception.Message } + Assert-Equal $message $case.Error + } + else { + $platform = Get-PlatformTriple + Assert-Equal $platform.ArchId $case.Arch + Assert-Equal $platform.TargetTriple $case.Triple + Assert-Equal $platform.PlatformKey "windows-$($case.Arch)" + } + } + Write-Host "Windows installer architecture regressions passed ($($PSVersionTable.PSVersion))" + } + finally { + $env:PROCESSOR_ARCHITECTURE = $oldProcessArch + $env:PROCESSOR_ARCHITEW6432 = $oldNativeArch + } +} +if ($ArchitectureOnly) { return } + $dir = 'C:\Users\Alice\.local\bin' foreach ($existing in @('', 'C:\WindowsApps', 'C:\A;C:\B')) { $script:UserPath = $existing From 3d1d7b5b8766723971b25036eac6a2640c7836b8 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 18:33:02 +0800 Subject: [PATCH 2/7] fix(installer): stop existing daemon when installing to a new directory --- install.ps1 | 11 ++++++----- scripts/install-windows.test.ps1 | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index d7b95571..a344a5c5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -132,18 +132,19 @@ function Install-Binary { $staged = "$Target.install-$([Guid]::NewGuid().ToString('N'))" try { [System.IO.File]::Copy($Source, $staged) + # The running daemon may come from another installation directory. + # Use the downloaded CLI: older versions have broken Windows liveness checks. + # Stop verifies daemon identity and uses the current BSK_HOME. + & $Source daemon stop + if ($LASTEXITCODE -ne 0) { throw "could not stop bsk daemon; installation was not changed" } if ([System.IO.File]::Exists($Target)) { - # Use the downloaded CLI: older versions have broken Windows - # liveness checks. Stop verifies daemon identity before terminating it. - & $Source daemon stop - if ($LASTEXITCODE -ne 0) { throw "could not stop bsk daemon; existing installation was not replaced" } # PowerShell 5.1 converts $null to an empty path for string parameters. [System.IO.File]::Replace($staged, $Target, [NullString]::Value) - Write-Log "daemon will restart automatically on the next browser command" } else { [System.IO.File]::Move($staged, $Target) } + Write-Log "daemon will restart automatically on the next browser command" } finally { if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) } diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index 9c9cd877..0555d23d 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -143,11 +143,36 @@ try { & $target --version if ($LASTEXITCODE -ne 0) { throw "replacement is not executable" } + # Installing into a new directory must also stop a daemon from the old one. + $newTargetDir = Join-Path $root "new install" + [IO.Directory]::CreateDirectory($newTargetDir) | Out-Null + $newTarget = Join-Path $newTargetDir "bsk.exe" + $before = (Get-FileHash -LiteralPath $target).Hash + $daemon = Start-Process -FilePath $target -ArgumentList @('daemon', 'start', '--foreground', '--port', '0') -WindowStyle Hidden -PassThru + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while (-not [IO.File]::Exists($infoPath)) { + if ($daemon.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw "old-directory daemon failed to start" } + Start-Sleep -Milliseconds 50 + } + Install-Binary $source $newTarget + if (-not $daemon.WaitForExit(5000)) { throw "new-directory install left the old daemon running" } + Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before + Assert-Equal (Get-FileHash -LiteralPath $newTarget).Hash (Get-FileHash -LiteralPath $source).Hash + & $newTarget --version + if ($LASTEXITCODE -ne 0) { throw "new-directory installation is not executable" } + if (@(Get-ChildItem -LiteralPath $newTargetDir -Filter "*.install-*").Count) { throw "new-directory staging files leaked" } + # Refuse replacement when daemon identity cannot be verified. [IO.File]::WriteAllText($infoPath, 'invalid daemon metadata') $before = (Get-FileHash -LiteralPath $target).Hash Assert-Fails { Install-Binary $source $target } Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before + + # A failed stop must also prevent installation to a previously empty target. + $blockedTarget = Join-Path $newTargetDir "blocked.exe" + Assert-Fails { Install-Binary $source $blockedTarget } + if (Test-Path -LiteralPath $blockedTarget) { throw "failed stop created an installation" } + if (@(Get-ChildItem -LiteralPath $newTargetDir -Filter "*.install-*").Count) { throw "failed stop leaked staging files" } [IO.File]::Delete($infoPath) # A remaining lock must fail without truncation or staging debris. From d57dcb56339f77789be780859c7cb4caa7d929e8 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 19:24:39 +0800 Subject: [PATCH 3/7] fix(installer): correct Windows paths and release checks --- install.ps1 | 49 +++++++------ scripts/install-windows.test.ps1 | 120 ++++++++++++++++++++++++++++--- 2 files changed, 137 insertions(+), 32 deletions(-) diff --git a/install.ps1 b/install.ps1 index a344a5c5..5fb4a796 100644 --- a/install.ps1 +++ b/install.ps1 @@ -3,7 +3,7 @@ install.ps1 — install the bsk CLI on Windows from GitHub Releases. .DESCRIPTION -Downloads the latest (or pinned) bsk release for Windows x64 or ARM64, +Downloads the latest (or pinned) bsk release for Windows x64, extracts bsk.exe to a user-local directory, and adds it to PATH. Usage: @@ -73,25 +73,21 @@ function Add-ToUserPath { $currentUserPath = @([Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ }) - if ($currentUserPath -contains $Dir) { - Write-Log "$Dir is already in your user PATH" + $newUserPath = (@($Dir) + @($currentUserPath | Where-Object { $_ -ine $Dir })) -join ";" + if (($currentUserPath -join ";") -ceq $newUserPath) { + Write-Log "$Dir is already first in your user PATH" return } - $newUserPath = ($currentUserPath + $Dir) -join ";" [Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User") - Write-Log "added ${Dir} to user PATH" + Write-Log "placed ${Dir} first in user PATH" } function Add-ToSessionPath { param([string]$Dir) - $pathEntries = $env:PATH -split ";" | Where-Object { $_ } - if ($pathEntries -contains $Dir) { - return - } - - $env:PATH = "$Dir;$env:PATH" + $pathEntries = @($env:PATH -split ";" | Where-Object { $_ -and $_ -ine $Dir }) + $env:PATH = (@($Dir) + $pathEntries) -join ";" } # ── Git Bash (bash environment) PATH helper ────────────────────────────────── @@ -173,14 +169,20 @@ function Main { Write-Log "latest version is ${version}" } - $archiveName = "bsk-v${version}-$($platform.TargetTriple).zip" - $downloadUrl = "${GitHub}/releases/download/${tag}/${archiveName}" - - $expectedSha = $null $platformKey = $platform.PlatformKey + $asset = $null if ($manifest -and $manifest.assets) { - $expectedSha = $manifest.assets.$platformKey.sha256 + $asset = $manifest.assets.$platformKey } + # ARM64 is not in the current release matrix. Require a published entry + # before attempting it, while preserving legacy x64 installs without a manifest. + if ($platform.ArchId -eq "arm64" -and -not $asset) { + Write-Die "version.json does not list a Windows ARM64 package for bsk $version" + } + + $archiveName = "bsk-v${version}-$($platform.TargetTriple).zip" + $downloadUrl = "${GitHub}/releases/download/${tag}/${archiveName}" + $expectedSha = if ($asset) { $asset.sha256 } else { $null } if (-not $expectedSha) { if (-not $manifest) { Write-Log "warning: could not fetch version.json; skipping checksum verification" @@ -201,7 +203,7 @@ function Main { if ($expectedSha) { Write-Log "verifying checksum" - $actualSha = (Get-FileHash -Algorithm SHA256 -Path $archivePath).Hash + $actualSha = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash if ($actualSha -ieq $expectedSha) { Write-Log "checksum OK" } @@ -211,13 +213,13 @@ function Main { } Write-Log "extracting ${archiveName}" - Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force + Expand-Archive -LiteralPath $archivePath -DestinationPath $tempDir -Force - if (-not (Test-Path (Join-Path $tempDir "bsk.exe"))) { + if (-not (Test-Path -LiteralPath (Join-Path $tempDir "bsk.exe"))) { Write-Die "bsk.exe not found in archive" } - if (-not (Test-Path $InstallDir)) { + if (-not (Test-Path -LiteralPath $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null } @@ -239,12 +241,17 @@ function Main { & $bskPath --version if ($LASTEXITCODE -ne 0) { throw "installed bsk failed verification" } + $command = Get-Command bsk -ErrorAction SilentlyContinue + if (-not $command -or $command.CommandType -ne "Application" -or $command.Source -ine $bskPath) { + Write-Log "warning: 'bsk' does not resolve to $bskPath; check Get-Command bsk -All for a conflicting command" + } + Write-Log "done" Write-Host "" Write-Host "Open a new terminal (PowerShell / Git Bash) for PATH changes to take full effect." } finally { - Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index 0555d23d..190ca26f 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -1,11 +1,11 @@ #Requires -Version 5.1 param( [string]$BskPath = (Join-Path $PSScriptRoot "../target/debug/bsk.exe"), - [switch]$ArchitectureOnly + [switch]$HelpersOnly ) $ErrorActionPreference = "Stop" -# Load definitions, never Main: no downloads or changes to the real user PATH. +# Load definitions without running Main. Registry PATH writes are isolated below. $installer = Join-Path $PSScriptRoot "../install.ps1" $tokens = $null $errors = $null @@ -69,22 +69,38 @@ function Assert-Fails([scriptblock]$Action) { $env:PROCESSOR_ARCHITEW6432 = $oldNativeArch } } -if ($ArchitectureOnly) { return } - $dir = 'C:\Users\Alice\.local\bin' -foreach ($existing in @('', 'C:\WindowsApps', 'C:\A;C:\B')) { - $script:UserPath = $existing - Add-ToUserPath $dir - $expected = if ($existing) { "$existing;$dir" } else { $dir } - Assert-Equal $script:UserPath $expected - Add-ToUserPath $dir - Assert-Equal $script:UserPath $expected +$pathCases = @( + @{ Existing = ''; Expected = $dir } + @{ Existing = 'C:\WindowsApps'; Expected = "$dir;C:\WindowsApps" } + @{ Existing = 'C:\A;C:\B'; Expected = "$dir;C:\A;C:\B" } + @{ Existing = "C:\old-bsk;$dir;C:\B;$($dir.ToUpperInvariant())"; Expected = "$dir;C:\old-bsk;C:\B" } +) +$oldPath = $env:PATH +try { + foreach ($case in $pathCases) { + $script:UserPath = $case.Existing + $env:PATH = $case.Existing + foreach ($attempt in 1..2) { + Add-ToUserPath $dir + Add-ToSessionPath $dir + Assert-Equal $script:UserPath $case.Expected + Assert-Equal $env:PATH $case.Expected + } + } +} finally { $env:PATH = $oldPath } +if ($HelpersOnly) { + Write-Host "Windows installer helper regressions passed ($($PSVersionTable.PSVersion))" + return } $root = Join-Path ([IO.Path]::GetTempPath()) ("bsk-install-test-" + [Guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($root) | Out-Null $oldBskHome = $env:BSK_HOME $oldAutoUpdate = $env:BSK_AUTO_UPDATE +$oldVersion = $env:BSK_VERSION +$oldProcessArch = $env:PROCESSOR_ARCHITECTURE +$oldNativeArch = $env:PROCESSOR_ARCHITEW6432 $daemon = $null try { $utf8 = New-Object System.Text.UTF8Encoding($false) @@ -180,6 +196,84 @@ try { try { Assert-Fails { Install-Binary $source $target } } finally { $lock.Dispose() } Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before if (@(Get-ChildItem -LiteralPath $targetDir -Filter "*.install-*").Count) { throw "staging files leaked" } + + # Exercise Main with real ZIPs/executables, isolating network and user state. + & { + $fixtureTemp = Join-Path $root 'temp [literal]' + [IO.Directory]::CreateDirectory($fixtureTemp) | Out-Null + $mainDefinition = ($ast.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $_.Name -eq 'Main' + }).Extent.Text.Replace('[System.IO.Path]::GetTempPath()', '$fixtureTemp') + Invoke-Expression $mainDefinition + function Write-Die([string]$Message) { throw $Message } + $bashProfile = (Get-Command Add-ToBashProfile).ScriptBlock + function Add-ToBashProfile([string]$Dir) { + & $bashProfile $Dir (Join-Path $root 'flow.bashrc') + } + $fixtureArchive = Join-Path $root 'release.zip' + Compress-Archive -LiteralPath $source -DestinationPath $fixtureArchive -CompressionLevel Fastest + $asset = @{ sha256 = (Get-FileHash -LiteralPath $fixtureArchive).Hash } + $fixtureManifest = @{ version = '9.8.7'; assets = @{ 'windows-x64' = $asset } } + $downloads = New-Object 'System.Collections.Generic.List[string]' + function Invoke-RestMethod([string]$Uri) { + if (-not $fixtureManifest) { throw 'fixture manifest unavailable' } + return $fixtureManifest + } + function Invoke-WebRequest { + [CmdletBinding()] + param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing) + $downloads.Add($Uri) + [IO.File]::Copy($fixtureArchive, $OutFile) + } + $GitHub = 'https://example.invalid/fixture' + $InstallDir = Join-Path $root 'installed [literal]' + $installed = Join-Path $InstallDir 'bsk.exe' + $env:BSK_VERSION = $null + $env:PROCESSOR_ARCHITECTURE = 'AMD64' + $env:PROCESSOR_ARCHITEW6432 = $null + $script:UserPath = "$targetDir;$InstallDir;$($InstallDir.ToUpperInvariant())" + $env:PATH = "$targetDir;$InstallDir;$oldPath" + Assert-Equal (Get-Command bsk -CommandType Application).Source $target + Main + Assert-Equal $downloads.Count 1 + Assert-Equal (Get-Command bsk -CommandType Application).Source $installed + Assert-Equal $script:UserPath "$InstallDir;$targetDir" + Assert-Equal (Get-FileHash -LiteralPath $installed).Hash (Get-FileHash -LiteralPath $source).Hash + Assert-Equal @(Get-ChildItem -LiteralPath $fixtureTemp -Force).Count 0 + + # ARM64 must be listed in the manifest before any archive is requested. + $env:PROCESSOR_ARCHITECTURE = 'ARM64' + $downloads.Clear() + $message = $null + try { Main } catch { $message = $_.Exception.Message } + Assert-Equal $message 'version.json does not list a Windows ARM64 package for bsk 9.8.7' + Assert-Equal $downloads.Count 0 + $env:BSK_VERSION = '9.8.7' + $fixtureManifest = $null + $message = $null + try { Main } catch { $message = $_.Exception.Message } + Assert-Equal $message 'version.json does not list a Windows ARM64 package for bsk 9.8.7' + Assert-Equal $downloads.Count 0 + + # Keep pinned x64 installs compatible with older releases without manifests. + $env:PROCESSOR_ARCHITECTURE = 'AMD64' + Main + Assert-Equal $downloads.Count 1 + Assert-Equal @(Get-ChildItem -LiteralPath $fixtureTemp -Force).Count 0 + + # A listed ARM64 archive remains selectable (the fixture uses the host EXE). + $fixtureManifest = @{ version = '9.8.7'; assets = @{ 'windows-arm64' = $asset } } + $env:PROCESSOR_ARCHITECTURE = 'ARM64' + Main + Assert-Equal $downloads[$downloads.Count - 1] "$GitHub/releases/download/cli-v9.8.7/bsk-v9.8.7-aarch64-pc-windows-msvc.zip" + + # A checksum failure must clean the bracketed temp path and preserve the install. + $before = (Get-FileHash -LiteralPath $installed).Hash + $fixtureManifest.assets['windows-arm64'] = @{ sha256 = '0' * 64 } + Assert-Fails { Main } + Assert-Equal (Get-FileHash -LiteralPath $installed).Hash $before + Assert-Equal @(Get-ChildItem -LiteralPath $fixtureTemp -Force).Count 0 + } Write-Host "Windows installer regressions passed ($($PSVersionTable.PSVersion))" } catch { @@ -194,6 +288,10 @@ finally { } $env:BSK_HOME = $oldBskHome $env:BSK_AUTO_UPDATE = $oldAutoUpdate + $env:BSK_VERSION = $oldVersion + $env:PROCESSOR_ARCHITECTURE = $oldProcessArch + $env:PROCESSOR_ARCHITEW6432 = $oldNativeArch + $env:PATH = $oldPath Remove-Item Env:BSK_TEST_RC -ErrorAction SilentlyContinue # Resolve and verify before recursive cleanup; only this fixture is removed. $resolvedRoot = [IO.Path]::GetFullPath($root) From 358e42196a6363a9b49d2e4e172d3747672718f2 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 19:31:03 +0800 Subject: [PATCH 4/7] fix(installer): extract bracketed paths on Windows PowerShell --- install.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 5fb4a796..85580124 100644 --- a/install.ps1 +++ b/install.ps1 @@ -213,7 +213,9 @@ function Main { } Write-Log "extracting ${archiveName}" - Expand-Archive -LiteralPath $archivePath -DestinationPath $tempDir -Force + # PowerShell 5.1's Expand-Archive treats the destination as a wildcard path. + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($archivePath, $tempDir) if (-not (Test-Path -LiteralPath (Join-Path $tempDir "bsk.exe"))) { Write-Die "bsk.exe not found in archive" From 2cd953297ae63939e8c91a0bb0bb5bfbd32b82da Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 19:36:10 +0800 Subject: [PATCH 5/7] test(installer): check the resolved command after installation --- scripts/install-windows.test.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index 190ca26f..479b33f7 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -233,10 +233,10 @@ try { $env:PROCESSOR_ARCHITEW6432 = $null $script:UserPath = "$targetDir;$InstallDir;$($InstallDir.ToUpperInvariant())" $env:PATH = "$targetDir;$InstallDir;$oldPath" - Assert-Equal (Get-Command bsk -CommandType Application).Source $target + Assert-Equal (Get-Command bsk).Source $target Main Assert-Equal $downloads.Count 1 - Assert-Equal (Get-Command bsk -CommandType Application).Source $installed + Assert-Equal (Get-Command bsk).Source $installed Assert-Equal $script:UserPath "$InstallDir;$targetDir" Assert-Equal (Get-FileHash -LiteralPath $installed).Hash (Get-FileHash -LiteralPath $source).Hash Assert-Equal @(Get-ChildItem -LiteralPath $fixtureTemp -Force).Count 0 From defa54293b6a66be47175adebce54a8a1d828061 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 23:16:46 +0800 Subject: [PATCH 6/7] test(installer): exercise real HTTP writes in bracketed paths --- scripts/install-windows.test.ps1 | 43 +++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index 479b33f7..0e3d793f 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -102,6 +102,7 @@ $oldVersion = $env:BSK_VERSION $oldProcessArch = $env:PROCESSOR_ARCHITECTURE $oldNativeArch = $env:PROCESSOR_ARCHITEW6432 $daemon = $null +$script:DownloadServer = $null try { $utf8 = New-Object System.Text.UTF8Encoding($false) $bashRc = Join-Path $root ".bashrc" @@ -212,6 +213,41 @@ try { } $fixtureArchive = Join-Path $root 'release.zip' Compress-Archive -LiteralPath $source -DestinationPath $fixtureArchive -CompressionLevel Fastest + # Serve the ZIP over loopback HTTP. Only the URL is redirected below; + # Invoke-WebRequest and the installer's disk writes remain real. + $script:DownloadServer = Start-Job -ArgumentList $fixtureArchive -ScriptBlock { + param($Archive) + $ErrorActionPreference = 'Stop' + $body = [IO.File]::ReadAllBytes($Archive) + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + Write-Output $listener.LocalEndpoint.Port + while ($true) { + if (-not $listener.Pending()) { Start-Sleep -Milliseconds 50; continue } + $client = $listener.AcceptTcpClient() + try { + $stream = $client.GetStream() + $stream.ReadTimeout = 5000 + $stream.WriteTimeout = 5000 + $reader = [IO.StreamReader]::new($stream) + while (($line = $reader.ReadLine()) -and $line.Length) { } + $header = [Text.Encoding]::ASCII.GetBytes("HTTP/1.1 200 OK`r`nContent-Type: application/octet-stream`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n") + $stream.Write($header, 0, $header.Length) + $stream.Write($body, 0, $body.Length) + } finally { $client.Dispose() } + } + } finally { $listener.Stop() } + } + $serverPort = $null + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while (-not $serverPort) { + $serverPort = Receive-Job $script:DownloadServer + if ($script:DownloadServer.State -ne 'Running' -or [DateTime]::UtcNow -gt $deadline) { + throw 'download fixture failed to start' + } + if (-not $serverPort) { Start-Sleep -Milliseconds 50 } + } $asset = @{ sha256 = (Get-FileHash -LiteralPath $fixtureArchive).Hash } $fixtureManifest = @{ version = '9.8.7'; assets = @{ 'windows-x64' = $asset } } $downloads = New-Object 'System.Collections.Generic.List[string]' @@ -223,7 +259,8 @@ try { [CmdletBinding()] param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing) $downloads.Add($Uri) - [IO.File]::Copy($fixtureArchive, $OutFile) + $PSBoundParameters['Uri'] = "http://127.0.0.1:$serverPort/release.zip" + Microsoft.PowerShell.Utility\Invoke-WebRequest @PSBoundParameters -TimeoutSec 15 } $GitHub = 'https://example.invalid/fixture' $InstallDir = Join-Path $root 'installed [literal]' @@ -282,6 +319,10 @@ catch { throw } finally { + if ($script:DownloadServer) { + Stop-Job $script:DownloadServer + Remove-Job $script:DownloadServer + } if ($daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force $daemon.WaitForExit(5000) | Out-Null From f995620b53e522ea440e29102bf9c38d8c4d31ba Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 23:22:42 +0800 Subject: [PATCH 7/7] fix(installer): complete literal paths and recovery guidance --- install.ps1 | 25 ++++++++++++++++--------- scripts/install-windows.test.ps1 | 26 +++++++++++++++++++------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/install.ps1 b/install.ps1 index 85580124..e52edad8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -131,8 +131,13 @@ function Install-Binary { # The running daemon may come from another installation directory. # Use the downloaded CLI: older versions have broken Windows liveness checks. # Stop verifies daemon identity and uses the current BSK_HOME. + # Fail closed for both new installs and replacements; never discard daemon metadata here. & $Source daemon stop - if ($LASTEXITCODE -ne 0) { throw "could not stop bsk daemon; installation was not changed" } + if ($LASTEXITCODE -ne 0) { + $daemonHome = if ($env:BSK_HOME) { $env:BSK_HOME } else { Join-Path $HOME ".bsk" } + $daemonInfoPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath((Join-Path $daemonHome "daemon.json")) + throw "could not stop bsk daemon; installation was not changed. Stop any running bsk daemon, remove '$daemonInfoPath', then retry the installer." + } if ([System.IO.File]::Exists($Target)) { # PowerShell 5.1 converts $null to an empty path for string parameters. [System.IO.File]::Replace($staged, $Target, [NullString]::Value) @@ -140,7 +145,6 @@ function Install-Binary { else { [System.IO.File]::Move($staged, $Target) } - Write-Log "daemon will restart automatically on the next browser command" } finally { if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) } @@ -193,13 +197,17 @@ function Main { } $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + [System.IO.Directory]::CreateDirectory($tempDir) | Out-Null try { $archivePath = Join-Path $tempDir $archiveName Write-Log "downloading ${downloadUrl}" - Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing -ErrorAction Stop + # PowerShell 5.1's -OutFile treats brackets as wildcards. Write raw HTTP bytes literally. + $response = Invoke-WebRequest -Uri $downloadUrl -UseBasicParsing -ErrorAction Stop + try { + [System.IO.File]::WriteAllBytes($archivePath, $response.RawContentStream.ToArray()) + } finally { $response.RawContentStream.Dispose() } if ($expectedSha) { Write-Log "verifying checksum" @@ -221,9 +229,7 @@ function Main { Write-Die "bsk.exe not found in archive" } - if (-not (Test-Path -LiteralPath $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - } + [System.IO.Directory]::CreateDirectory($InstallDir) | Out-Null Install-Binary -Source (Join-Path $tempDir "bsk.exe") -Target (Join-Path $InstallDir "bsk.exe") @@ -245,12 +251,13 @@ function Main { $command = Get-Command bsk -ErrorAction SilentlyContinue if (-not $command -or $command.CommandType -ne "Application" -or $command.Source -ine $bskPath) { - Write-Log "warning: 'bsk' does not resolve to $bskPath; check Get-Command bsk -All for a conflicting command" + Write-Log "warning: 'bsk' does not resolve to $bskPath in this session; check Get-Command bsk -All for a conflicting command" } Write-Log "done" Write-Host "" - Write-Host "Open a new terminal (PowerShell / Git Bash) for PATH changes to take full effect." + Write-Host "This PATH check covers the current session only; a new terminal may prefer a Machine PATH entry or alias." + Write-Host "In a new PowerShell terminal, run Get-Command bsk -All and confirm the first result is $bskPath." } finally { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index 0e3d793f..9d70b84b 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -24,9 +24,12 @@ foreach ($statement in $ast.EndBlock.Statements) { function Assert-Equal($Actual, $Expected) { if ($Actual -cne $Expected) { throw "expected [$Expected], got [$Actual]" } } -function Assert-Fails([scriptblock]$Action) { +function Assert-Fails([scriptblock]$Action, [string]$MessageContains) { $failed = $false - try { & $Action } catch { $failed = $true } + try { & $Action } catch { + $failed = $true + if ($MessageContains -and -not $_.Exception.Message.Contains($MessageContains)) { throw } + } if (-not $failed) { throw "expected failure" } } @@ -127,7 +130,7 @@ try { if ($LASTEXITCODE -ne 0) { throw "Git Bash failed" } Assert-Equal $actual ('/c' + $special.Substring(2).Replace('\', '/')) - $env:BSK_HOME = Join-Path $root "home" + $env:BSK_HOME = Join-Path $root "home [state]" $env:BSK_AUTO_UPDATE = "off" [IO.Directory]::CreateDirectory($env:BSK_HOME) | Out-Null $sourceDir = Join-Path $root "download" @@ -137,7 +140,8 @@ try { $targetDir = Join-Path $root "中文 space [x] & install" [IO.Directory]::CreateDirectory($targetDir) | Out-Null $target = Join-Path $targetDir "bsk.exe" - Install-Binary $source $target + $firstInstallOutput = Install-Binary $source $target 6>&1 | Out-String + if ($firstInstallOutput -match 'daemon will restart') { throw 'fresh install reported a daemon restart' } Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash $daemon = Start-Process -FilePath $target -ArgumentList @('daemon', 'start', '--foreground', '--port', '0') -WindowStyle Hidden -PassThru @@ -182,15 +186,19 @@ try { # Refuse replacement when daemon identity cannot be verified. [IO.File]::WriteAllText($infoPath, 'invalid daemon metadata') $before = (Get-FileHash -LiteralPath $target).Hash - Assert-Fails { Install-Binary $source $target } + Assert-Fails { Install-Binary $source $target } -MessageContains $infoPath Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before # A failed stop must also prevent installation to a previously empty target. $blockedTarget = Join-Path $newTargetDir "blocked.exe" - Assert-Fails { Install-Binary $source $blockedTarget } + Assert-Fails { Install-Binary $source $blockedTarget } -MessageContains $infoPath if (Test-Path -LiteralPath $blockedTarget) { throw "failed stop created an installation" } if (@(Get-ChildItem -LiteralPath $newTargetDir -Filter "*.install-*").Count) { throw "failed stop leaked staging files" } + Assert-Equal ([IO.File]::ReadAllText($infoPath)) 'invalid daemon metadata' + # Following the reported recovery path must allow the same installation to succeed. [IO.File]::Delete($infoPath) + Install-Binary $source $blockedTarget + Assert-Equal (Get-FileHash -LiteralPath $blockedTarget).Hash (Get-FileHash -LiteralPath $source).Hash # A remaining lock must fail without truncation or staging debris. $lock = [IO.File]::Open($target, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) @@ -271,7 +279,11 @@ try { $script:UserPath = "$targetDir;$InstallDir;$($InstallDir.ToUpperInvariant())" $env:PATH = "$targetDir;$InstallDir;$oldPath" Assert-Equal (Get-Command bsk).Source $target - Main + $installOutput = Main 6>&1 | Out-String + if ($installOutput -notmatch 'current session only' -or + $installOutput -notmatch 'Machine PATH' -or $installOutput -notmatch 'Get-Command bsk -All') { + throw 'install did not explain PATH verification in a new terminal' + } Assert-Equal $downloads.Count 1 Assert-Equal (Get-Command bsk).Source $installed Assert-Equal $script:UserPath "$InstallDir;$targetDir"