diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0109728b38a..27d30cb31ea 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -88,6 +88,40 @@ try { # Mojibake on output is then cosmetic-only, install still works. } +# ============================================================================ +# 8.3 short-path normalization +# ============================================================================ +# When the Windows user-profile folder name contains a space (e.g. +# "First Last"), Windows generates an 8.3 short alias for it (e.g. FIRST~1.LAS) +# and may expose %TEMP%/%TMP% in that short form: +# C:\Users\FIRST~1.LAS\AppData\Local\Temp +# PowerShell's FileSystem provider mishandles the "~1.ext" component when such a +# path is handed to a provider cmdlet like `Tee-Object -FilePath` / +# `Out-File -FilePath`, throwing: +# "An object at the specified path C:\Users\FIRST~1.LAS does not exist." +# Every Node/Electron build+install stage streams its log to %TEMP% via +# Tee-Object, so they all abort with that error, while the Python/uv stages -- +# which never write a side log to %TEMP% through a provider cmdlet -- complete +# fine. Expanding %TEMP%/%TMP% back to their long form once, up front, lets +# every downstream cmdlet (and child process) see a path the provider can +# resolve. (GH: Windows desktop installer fails at Node/Electron stages.) + +function ConvertTo-LongPath { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $Path } + # Only 8.3 short names carry a tilde+digit ("~1"); skip the COM round-trip + # for ordinary long paths. + if ($Path -notmatch '~\d') { return $Path } + try { + $fso = New-Object -ComObject Scripting.FileSystemObject + if ($fso.FolderExists($Path)) { return $fso.GetFolder($Path).Path } + if ($fso.FileExists($Path)) { return $fso.GetFile($Path).Path } + } catch { + # COM unavailable / locked-down host: fall back to the original path. + } + return $Path +} + # ============================================================================ # Configuration # ============================================================================ diff --git a/scripts/tests/test-install-ps1-longpath.ps1 b/scripts/tests/test-install-ps1-longpath.ps1 new file mode 100644 index 00000000000..a93acb0d9ab --- /dev/null +++ b/scripts/tests/test-install-ps1-longpath.ps1 @@ -0,0 +1,86 @@ +# Unit tests for install.ps1's ConvertTo-LongPath helper. +# +# Run from a PowerShell prompt: +# +# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1 +# +# Background: on a Windows profile whose folder name contains a space (e.g. +# "First Last"), %TEMP%/%TMP% can be exposed as an 8.3 short path +# (C:\Users\FIRST~1.LAS\...). PowerShell's FileSystem provider chokes on the +# "~1.ext" component when it reaches a provider cmdlet (Tee-Object -FilePath), +# aborting the Node/Electron install+build stages. install.ps1 expands such +# paths to their long form up front; this verifies the helper's contract. +# +# We extract just the function from install.ps1 via the AST so the installer's +# top-level body never runs (dot-sourcing would execute the whole script). +# The COM-backed expansion only fires for inputs containing "~"; the +# pass-through and graceful-fallback paths are assertable on any host (incl. +# non-Windows pwsh, where the COM object is simply unavailable). + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) +$installScript = Join-Path $repoRoot "scripts/install.ps1" + +if (-not (Test-Path $installScript)) { + throw "Could not locate install.ps1 at $installScript" +} + +$failures = 0 +function Assert-Equal { + param([Parameter(Mandatory = $true)] $Expected, + [Parameter(Mandatory = $true)] $Actual, + [Parameter(Mandatory = $true)] [string]$Label) + if ($Expected -ne $Actual) { + Write-Host "FAIL: $Label" -ForegroundColor Red + Write-Host " expected: $Expected" + Write-Host " actual: $Actual" + $script:failures++ + } else { + Write-Host "OK: $Label" -ForegroundColor Green + } +} + +# --- Load ConvertTo-LongPath from install.ps1 without executing the script --- +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($installScript, [ref]$tokens, [ref]$errors) +$fnAst = $ast.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'ConvertTo-LongPath' + }, $true) | Select-Object -First 1 + +if (-not $fnAst) { + throw "ConvertTo-LongPath not found in install.ps1 -- did the helper get renamed/removed?" +} +. ([scriptblock]::Create($fnAst.Extent.Text)) + +# --- Tests --- +Write-Host "" +Write-Host "-- ConvertTo-LongPath --" + +Assert-Equal -Expected "" -Actual (ConvertTo-LongPath "") -Label "empty string returns empty" +Assert-Equal -Expected $null -Actual (ConvertTo-LongPath $null) -Label "null returns null" + +# No 8.3 component -> returned verbatim (even with spaces). +$longish = "C:\Users\First Last\AppData\Local\Temp" +Assert-Equal -Expected $longish -Actual (ConvertTo-LongPath $longish) -Label "long path with spaces is unchanged" + +$noTilde = "/tmp/some/long/path" +Assert-Equal -Expected $noTilde -Actual (ConvertTo-LongPath $noTilde) -Label "tilde-free path is unchanged" + +# Looks like an 8.3 name but does not exist -> graceful fallback to the input +# (FolderExists/FileExists both false, or COM unavailable on this host). +$fakeShort = "C:\Users\FIRST~1.LAS\does\not\exist" +Assert-Equal -Expected $fakeShort -Actual (ConvertTo-LongPath $fakeShort) -Label "nonexistent 8.3 path falls back to input" + +# --- Summary --- +Write-Host "" +if ($failures -gt 0) { + Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red + exit 1 +} else { + Write-Host "All ConvertTo-LongPath tests passed." -ForegroundColor Green + exit 0 +}