diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8f30ff1..6e12c10 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -267,6 +267,37 @@ jobs: call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4 + - name: Stage plugins for Windows installer + if: runner.os == 'Windows' + shell: bash + run: | + rm -rf plugin/dist-installer + mkdir -p plugin/dist-installer/VST3 + for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform; do + vst3_dir="plugin/build/${art}_artefacts/Release/VST3" + [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \; + done + vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')" + if [ "$vst3_count" -ne 7 ]; then + echo "ERROR: expected 7 VST3 plugins, found $vst3_count" + exit 1 + fi + + - name: Stage Prism TUI for Windows installer + if: runner.os == 'Windows' + run: npm run stage:tui + + - name: Build Windows NSIS smoke artifact + if: runner.os == 'Windows' + run: npx electron-builder --win nsis --publish never + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + + - name: Smoke-test Windows installer PATH integration + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/test-windows-installer.ps1 + - name: Show sccache stats if: runner.os != 'Linux' run: sccache --show-stats diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a78671d..7011835 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -281,6 +281,11 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: 'false' + - name: Smoke-test Windows installer PATH integration + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/test-windows-installer.ps1 + - name: Build Linux packages with VST3 plugins if: runner.os == 'Linux' shell: bash diff --git a/README.md b/README.md index b9f50a4..759772c 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ Prebuilt versions of Prism for Windows, macOS, and Linux are available on the [R Installable packages include the desktop application and the components supported by that package, including DAW plugins and `prism-tui` where applicable. +On Windows, the NSIS installer adds `prism-tui` to the machine `PATH`. Open a new terminal after installation so it inherits the updated environment. The portable Windows package does not modify `PATH`. + ## Building from Source **Prerequisites:** Node.js 18+, npm, CMake 3.22+, and a C++ compiler toolchain. diff --git a/package.json b/package.json index be0afab..673c483 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "postinstall": "node scripts/build/postinstall-native.cjs", "dist": "node scripts/build/dist-current.cjs", "dist:mac": "npm run stage:tui && npm run build && electron-builder --mac --publish never", - "dist:win": "npm run stage:tui && npm run build && electron-builder --win --publish never", + "dist:win": "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/build/check-windows-dist-output.ps1 && npm run stage:tui && npm run build && electron-builder --win --publish never", "dist:linux": "npm run dist:linux:appimage && npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never", "dist:linux:appimage": "npm run build && electron-builder --config electron-builder.appimage.cjs --linux AppImage --publish never", "dist:linux:packages": "npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never" diff --git a/resources/installer/windows-vst3.nsh b/resources/installer/windows-vst3.nsh index 0af3e19..fee59d8 100644 --- a/resources/installer/windows-vst3.nsh +++ b/resources/installer/windows-vst3.nsh @@ -61,10 +61,20 @@ Abort ${EndIf} + ; Pass the path through the installer's process environment. Supplying it + ; after PowerShell's -Command argument loses quotes at the native command + ; line boundary and truncates the default path to "C:\Program". + System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("PRISM_TUI_INSTALL_DIR", "$INSTDIR\resources\tui").r0' + ${If} $0 == 0 + DetailPrint "ERROR: could not prepare the Prism TUI PATH update" + MessageBox MB_OK|MB_ICONSTOP "Prism could not prepare the prism-tui PATH update." /SD IDOK + Abort + ${EndIf} + ; PowerShell avoids NSIS string-length truncation on machines with a large - ; PATH. It removes exact duplicates, appends Prism once, and leaves every - ; other entry intact. PowerShell comparisons are case-insensitive here. - nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param([string]$$entry) $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry }); $$entries += $$entry; [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine') }" "$INSTDIR\resources\tui"` + ; PATH. It removes exact duplicates, safely repairs the truncated entry + ; written by older installers, appends Prism once, and verifies the write. + nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference = 'Stop'; $$entry = $$env:PRISM_TUI_INSTALL_DIR; if ([string]::IsNullOrWhiteSpace($$entry)) { exit 10 }; $$legacyEntry = $$null; $$removeLegacy = $$false; if ($$entry -match '\s') { $$legacyEntry = ($$entry -split '\s', 2)[0]; $$removeLegacy = $$legacyEntry -ine $$entry -and -not (Test-Path -LiteralPath $$legacyEntry -PathType Container) }; $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry -and (-not $$removeLegacy -or $$_ -ine $$legacyEntry) }); $$entries += $$entry; [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine'); $$updatedPath = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$matches = @($$updatedPath -split ';' | Where-Object { $$_ -ieq $$entry }); if ($$matches.Count -ne 1) { exit 11 } }"` Pop $0 ${If} $0 != 0 DetailPrint "ERROR: could not add Prism TUI to the machine PATH (exit code $0)" @@ -78,20 +88,21 @@ DetailPrint "Installing Prism VST3 plugins to $COMMONFILES64\VST3" ${IfNot} ${FileExists} "$INSTDIR\resources\plugins\VST3\*.vst3" - DetailPrint "ERROR: bundled Prism VST3 plugins were not found at $INSTDIR\resources\plugins\VST3" - MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugins were selected, but the bundled plugin files were not found.$\r$\n$\r$\nMissing path:$\r$\n$INSTDIR\resources\plugins\VST3" /SD IDOK - Abort - ${EndIf} - - CreateDirectory "$COMMONFILES64\VST3" - ; xcopy /E recurse, /I treat dest as dir, /Y overwrite without prompt. - ; The trailing "\*" + "/E" copies each *.vst3 bundle subfolder verbatim. - nsExec::ExecToLog 'cmd.exe /c xcopy /E /I /Y "$INSTDIR\resources\plugins\VST3\*" "$COMMONFILES64\VST3\"' - Pop $0 - ${If} $0 != 0 - DetailPrint "ERROR: Prism VST3 plugin copy failed with xcopy exit code $0" - MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugin installation failed while copying files to:$\r$\n$COMMONFILES64\VST3$\r$\n$\r$\nxcopy exit code: $0" /SD IDOK - Abort + ; Plugins are optional. Local app/TUI builds may intentionally omit the + ; staged bundles, so do not roll back an otherwise valid installation. + DetailPrint "WARNING: bundled Prism VST3 plugins were not found; skipping plugin installation" + MessageBox MB_OK|MB_ICONEXCLAMATION "Prism was installed, but this package does not contain the optional VST3 plugins.$\r$\n$\r$\nprism-tui and the desktop app are ready to use." /SD IDOK + ${Else} + CreateDirectory "$COMMONFILES64\VST3" + ; xcopy /E recurse, /I treat dest as dir, /Y overwrite without prompt. + ; The trailing "\*" + "/E" copies each *.vst3 bundle subfolder verbatim. + nsExec::ExecToLog 'cmd.exe /c xcopy /E /I /Y "$INSTDIR\resources\plugins\VST3\*" "$COMMONFILES64\VST3\"' + Pop $0 + ${If} $0 != 0 + DetailPrint "ERROR: Prism VST3 plugin copy failed with xcopy exit code $0" + MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugin installation failed while copying files to:$\r$\n$COMMONFILES64\VST3$\r$\n$\r$\nxcopy exit code: $0" /SD IDOK + Abort + ${EndIf} ${EndIf} ${Else} DetailPrint "Prism VST3 plugins: skipped (opted out)." @@ -102,12 +113,17 @@ !macro customUnInstall ; Remove only Prism's exact machine-PATH entry. A failure is non-fatal so an ; otherwise valid uninstall is never blocked. - nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param([string]$$entry) $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry }); [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine') }" "$INSTDIR\resources\tui"` - Pop $0 + System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("PRISM_TUI_INSTALL_DIR", "$INSTDIR\resources\tui").r0' ${If} $0 == 0 - SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + DetailPrint "WARNING: could not prepare the Prism TUI PATH removal" ${Else} - DetailPrint "WARNING: could not remove Prism TUI from the machine PATH (exit code $0)" + nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference = 'Stop'; $$entry = $$env:PRISM_TUI_INSTALL_DIR; if ([string]::IsNullOrWhiteSpace($$entry)) { exit 10 }; $$legacyEntry = $$null; $$removeLegacy = $$false; if ($$entry -match '\s') { $$legacyEntry = ($$entry -split '\s', 2)[0]; $$removeLegacy = $$legacyEntry -ine $$entry -and -not (Test-Path -LiteralPath $$legacyEntry -PathType Container) }; $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry -and (-not $$removeLegacy -or $$_ -ine $$legacyEntry) }); [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine'); $$updatedPath = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$matches = @($$updatedPath -split ';' | Where-Object { $$_ -ieq $$entry }); if ($$matches.Count -ne 0) { exit 11 } }"` + Pop $0 + ${If} $0 == 0 + SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + ${Else} + DetailPrint "WARNING: could not remove Prism TUI from the machine PATH (exit code $0)" + ${EndIf} ${EndIf} ; Always attempt removal — harmless RMDir if the bundle isn't there (user opted diff --git a/scripts/build/check-windows-dist-output.ps1 b/scripts/build/check-windows-dist-output.ps1 new file mode 100644 index 0000000..a6f06da --- /dev/null +++ b/scripts/build/check-windows-dist-output.ps1 @@ -0,0 +1,42 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$packageJson = Get-Content -LiteralPath (Join-Path $repoRoot 'package.json') -Raw | ConvertFrom-Json +$productName = [string]$packageJson.build.productName +$version = [string]$packageJson.version +$outputDirectory = Join-Path $repoRoot ([string]$packageJson.build.directories.output) + +$outputFiles = @( + (Join-Path $outputDirectory "$productName Setup $version.exe"), + (Join-Path $outputDirectory "$productName $version.exe"), + (Join-Path $outputDirectory "win-unpacked\$productName.exe") +) +$lockedFiles = New-Object Collections.Generic.List[string] + +foreach ($outputFile in $outputFiles) { + if (-not (Test-Path -LiteralPath $outputFile -PathType Leaf)) { + continue + } + + try { + $stream = [IO.File]::Open( + $outputFile, + [IO.FileMode]::Open, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None + ) + $stream.Close() + } catch { + $lockedFiles.Add($outputFile) + } +} + +if ($lockedFiles.Count -gt 0) { + [Console]::Error.WriteLine('Cannot rebuild the Windows distribution because an output executable is still in use.') + [Console]::Error.WriteLine('Close the Prism app and installer, then run `npm run dist` again:') + foreach ($lockedFile in $lockedFiles) { + [Console]::Error.WriteLine(" $lockedFile") + } + exit 1 +} diff --git a/scripts/build/dist-current.cjs b/scripts/build/dist-current.cjs index c805cb0..e64c5ea 100644 --- a/scripts/build/dist-current.cjs +++ b/scripts/build/dist-current.cjs @@ -12,6 +12,21 @@ if (!script) { process.exit(1) } -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' -const result = spawnSync(npmCommand, ['run', script], { stdio: 'inherit' }) +const npmCli = process.env.npm_execpath +if (!npmCli) { + console.error('Could not locate the npm CLI. Run this command through `npm run dist`.') + process.exit(1) +} + +// Node 22 rejects direct child-process launches of Windows .cmd shims with +// EINVAL. Reuse the npm CLI that launched this script through the current Node +// executable instead, which is portable and avoids an extra command shell. +const result = spawnSync(process.execPath, [npmCli, 'run', script], { + stdio: 'inherit', +}) +if (result.error) { + console.error(`Could not start npm run ${script}: ${result.error.message}`) + process.exit(1) +} + process.exit(result.status ?? 1) diff --git a/scripts/test-windows-installer.ps1 b/scripts/test-windows-installer.ps1 new file mode 100644 index 0000000..49596e6 --- /dev/null +++ b/scripts/test-windows-installer.ps1 @@ -0,0 +1,228 @@ +[CmdletBinding()] +param( + [string]$InstallerPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($env:OS -ne 'Windows_NT') { + throw 'The Windows installer smoke test can only run on Windows.' +} + +if ($env:GITHUB_ACTIONS -ne 'true') { + throw 'This test changes the machine PATH and is restricted to the disposable GitHub Actions runner.' +} + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'The Windows installer smoke test requires an elevated process.' +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($InstallerPath)) { + $installerCandidates = @( + Get-ChildItem -LiteralPath (Join-Path $repoRoot 'dist') -Filter '*.exe' -File | + Where-Object { $_.Name -like '*Setup*.exe' } + ) + if ($installerCandidates.Count -ne 1) { + throw "Expected one NSIS setup executable in dist, found $($installerCandidates.Count)." + } + $InstallerPath = $installerCandidates[0].FullName +} + +$InstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path +$tempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [IO.Path]::GetTempPath() +} else { + $env:RUNNER_TEMP +} +$testId = [Guid]::NewGuid().ToString('N') +$installRoot = Join-Path $tempRoot "Prism-$testId Installer Smoke" +$tuiDirectory = Join-Path $installRoot 'resources\tui' +$tuiExecutable = Join-Path $tuiDirectory 'prism-tui.exe' +$uninstallerPath = Join-Path $installRoot 'Uninstall Prism.exe' +$legacyEntry = ($tuiDirectory -split '\s', 2)[0] +$machinePathBefore = [Environment]::GetEnvironmentVariable('Path', 'Machine') +$uninstalled = $false +$legacyDirectoryCreated = $false + +function Get-PathEntries { + param([AllowNull()][string]$Value) + + return @($Value -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} + +function Assert-PathEntriesEqual { + param( + [string[]]$Expected, + [string[]]$Actual, + [string]$Context + ) + + if ($Expected.Count -ne $Actual.Count) { + throw "$Context entry count mismatch. Expected $($Expected.Count), got $($Actual.Count)." + } + + for ($index = 0; $index -lt $Expected.Count; $index += 1) { + if (-not [string]::Equals($Expected[$index], $Actual[$index], [StringComparison]::OrdinalIgnoreCase)) { + throw "$Context differs at index $index. Expected '$($Expected[$index])', got '$($Actual[$index])'." + } + } +} + +function Invoke-CheckedProcess { + param( + [Parameter(Mandatory)][string]$FilePath, + [string]$Arguments = '', + [hashtable]$Environment = @{}, + [switch]$CaptureOutput + ) + + $startInfo = New-Object Diagnostics.ProcessStartInfo + $startInfo.FileName = $FilePath + $startInfo.Arguments = $Arguments + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.WorkingDirectory = $repoRoot + $startInfo.RedirectStandardOutput = $CaptureOutput + $startInfo.RedirectStandardError = $CaptureOutput + foreach ($name in $Environment.Keys) { + $startInfo.EnvironmentVariables[$name] = [string]$Environment[$name] + } + + $process = New-Object Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "Could not start $FilePath." + } + + $stdout = '' + $stderr = '' + if ($CaptureOutput) { + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + } + $process.WaitForExit() + + if ($process.ExitCode -ne 0) { + throw "$FilePath exited with code $($process.ExitCode).`n$stdout`n$stderr" + } + + return [pscustomobject]@{ + Stdout = $stdout + Stderr = $stderr + } +} + +try { + if ([string]::Equals($legacyEntry, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase)) { + throw "The smoke-test installation path must contain whitespace: $installRoot" + } + if (Test-Path -LiteralPath $legacyEntry -PathType Container) { + throw "Refusing to use an existing directory as the legacy PATH fixture: $legacyEntry" + } + + $originalEntries = @(Get-PathEntries $machinePathBefore) + foreach ($entry in $originalEntries) { + if ([string]::Equals($entry, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($entry, $legacyEntry, [StringComparison]::OrdinalIgnoreCase)) { + throw "The machine PATH already contains a smoke-test entry: $entry" + } + } + + $seededEntries = @($originalEntries) + $legacyEntry + [Environment]::SetEnvironmentVariable('Path', ($seededEntries -join ';'), 'Machine') + + # NSIS requires /D to be the final argument and consumes the remainder of the + # command line as the install directory, including spaces. + Invoke-CheckedProcess -FilePath $InstallerPath -Arguments "/S /D=$installRoot" | Out-Null + + if (-not (Test-Path -LiteralPath $tuiExecutable -PathType Leaf)) { + throw "The installer did not install prism-tui.exe at $tuiExecutable." + } + if (-not (Test-Path -LiteralPath $uninstallerPath -PathType Leaf)) { + throw "The installer did not create the expected uninstaller at $uninstallerPath." + } + + $installedEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine'))) + $expectedInstalledEntries = @($originalEntries) + $tuiDirectory + Assert-PathEntriesEqual -Expected $expectedInstalledEntries -Actual $installedEntries -Context 'Installed machine PATH' + + $matchingTuiEntries = @( + $installedEntries | Where-Object { + [string]::Equals($_, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase) + } + ) + if ($matchingTuiEntries.Count -ne 1) { + throw "Expected one Prism TUI PATH entry, found $($matchingTuiEntries.Count)." + } + if ($installedEntries -icontains $legacyEntry) { + throw "The installer left the legacy truncated PATH entry in place: $legacyEntry" + } + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $launchPath = (@($installedEntries) + @(Get-PathEntries $userPath)) -join ';' + $launchResult = Invoke-CheckedProcess -FilePath $env:ComSpec ` + -Arguments '/D /C prism-tui.exe --version' ` + -Environment @{ Path = $launchPath } ` + -CaptureOutput + if ($launchResult.Stdout.Trim() -notmatch '^prism-tui\s+') { + throw "prism-tui did not return the expected version output: $($launchResult.Stdout.Trim())" + } + + # Reinstall with the legacy prefix backed by a real directory. The safe + # migration must now preserve that entry while continuing to deduplicate the + # complete Prism TUI path. + New-Item -ItemType Directory -Path $legacyEntry | Out-Null + $legacyDirectoryCreated = $true + [Environment]::SetEnvironmentVariable('Path', ((@($installedEntries) + $legacyEntry) -join ';'), 'Machine') + Invoke-CheckedProcess -FilePath $InstallerPath -Arguments "/S /D=$installRoot" | Out-Null + + $reinstalledEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine'))) + $expectedReinstalledEntries = @($originalEntries) + $legacyEntry + $tuiDirectory + Assert-PathEntriesEqual -Expected $expectedReinstalledEntries -Actual $reinstalledEntries -Context 'Reinstalled machine PATH' + + Invoke-CheckedProcess -FilePath $uninstallerPath -Arguments '/S' | Out-Null + $uninstalled = $true + + $uninstalledEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine'))) + $expectedUninstalledEntries = @($originalEntries) + $legacyEntry + Assert-PathEntriesEqual -Expected $expectedUninstalledEntries -Actual $uninstalledEntries -Context 'Uninstalled machine PATH' + if ($uninstalledEntries -icontains $tuiDirectory) { + throw "The uninstaller left the Prism TUI PATH entry in place: $tuiDirectory" + } + + Write-Host "Windows installer PATH smoke test passed for $InstallerPath" +} finally { + if (-not $uninstalled -and (Test-Path -LiteralPath $uninstallerPath -PathType Leaf)) { + try { + Invoke-CheckedProcess -FilePath $uninstallerPath -Arguments '/S' | Out-Null + } catch { + Write-Warning "Smoke-test uninstall cleanup failed: $($_.Exception.Message)" + } + } + + [Environment]::SetEnvironmentVariable('Path', $machinePathBefore, 'Machine') + + if (Test-Path -LiteralPath $installRoot) { + $fullTempRoot = [IO.Path]::GetFullPath($tempRoot).TrimEnd('\') + '\' + $fullInstallRoot = [IO.Path]::GetFullPath($installRoot) + if ($fullInstallRoot.StartsWith($fullTempRoot, [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $fullInstallRoot -Recurse -Force + } else { + Write-Warning "Refusing to remove unexpected smoke-test directory: $fullInstallRoot" + } + } + + if ($legacyDirectoryCreated -and (Test-Path -LiteralPath $legacyEntry -PathType Container)) { + $fullTempRoot = [IO.Path]::GetFullPath($tempRoot).TrimEnd('\') + '\' + $fullLegacyEntry = [IO.Path]::GetFullPath($legacyEntry) + if ($fullLegacyEntry.StartsWith($fullTempRoot, [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $fullLegacyEntry -Recurse -Force + } else { + Write-Warning "Refusing to remove unexpected legacy fixture directory: $fullLegacyEntry" + } + } +}