| @@ -1,29 +1,33 @@ | |||
| <# | |||
| .SYNOPSIS | |||
| Packages this repository and deploys it to IIS over Tailscale/OpenSSH. | |||
| Validates, packages, and deploys this repository to its dedicated IIS site. | |||
| .DESCRIPTION | |||
| This is the controller/CI entry point. It creates a ZIP containing the full | |||
| repository (excluding VCS and local deployment artifacts), computes SHA-256, | |||
| copies the package and host installer with scp, then invokes the installer on | |||
| the Windows host through ssh. | |||
| -DryRun performs local validation and prints the remote operations without | |||
| connecting. Gitea 1.11.4 does not run this itself; use a trusted external CI | |||
| worker, a scheduled task, or an operator workstation. | |||
| The controller packages the complete repository, computes SHA-256, transfers | |||
| the package and host installer over OpenSSH, and invokes the installer. | |||
| -LocalPreflightOnly validates source provenance, XML, package layout, and | |||
| archive safety without connecting. -PreflightOnly additionally streams the | |||
| installer to the host and runs its read-only IIS preflight without writing a | |||
| remote installer or package. | |||
| #> | |||
| [CmdletBinding()] | |||
| param( | |||
| [Parameter(Mandatory = $true)] | |||
| [ValidatePattern('^[A-Za-z0-9_. -]+$')] | |||
| [string]$SiteName, | |||
| [string]$SiteName = 'AspClassicUnifiedFramework', | |||
| [ValidatePattern('^[A-Za-z0-9_. -]+$')] | |||
| [string]$AppPoolName = 'AspClassicUnifiedFramework', | |||
| [string]$DeployRoot = 'D:\Deployments\AspClassicUnifiedFramework', | |||
| [string]$BindingIpAddress = '100.97.39.23', | |||
| [ValidateRange(1, 65535)] | |||
| [int]$BindingPort = 8085, | |||
| [AllowEmptyString()] | |||
| [string]$HostHeader = '', | |||
| [string]$InitialWebConfigPath = '', | |||
| [string]$RemoteTarget = 'webserver-1', | |||
| [ValidateRange(1, 65535)] | |||
| [int]$RemotePort = 22, | |||
| [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), | |||
| [string]$DeployRoot = '', | |||
| [string]$BaseUrl = '', | |||
| [string]$ReleaseId = '', | |||
| [string]$ExpectedBranch = 'master', | |||
| @@ -35,7 +39,9 @@ param( | |||
| [switch]$AllowDirty, | |||
| [switch]$RunMigrations, | |||
| [switch]$SkipSmokeTest, | |||
| [switch]$RemotePreflightOnly, | |||
| [Alias('HostPreflightOnly', 'RemotePreflightOnly')] | |||
| [switch]$PreflightOnly, | |||
| [switch]$LocalPreflightOnly, | |||
| [switch]$Rollback, | |||
| [string]$RollbackTo = '', | |||
| [switch]$DryRun, | |||
| @@ -45,10 +51,7 @@ param( | |||
| Set-StrictMode -Version 2.0 | |||
| $ErrorActionPreference = 'Stop' | |||
| function Write-Step { | |||
| param([string]$Message) | |||
| Write-Host ('==> ' + $Message) | |||
| } | |||
| function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } | |||
| function Assert-Command { | |||
| param([string]$Name) | |||
| @@ -72,177 +75,232 @@ function Add-RemoteArgument { | |||
| $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) | |||
| } | |||
| function Assert-SafeNameValue { | |||
| param([string]$Name, [string]$Value) | |||
| if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_. -]+$') { | |||
| throw "$Name contains unsupported characters." | |||
| } | |||
| if ($Value -match '(?i)schedulicious') { | |||
| throw "$Name must not identify a Schedulicious resource." | |||
| } | |||
| } | |||
| function Assert-SafeDeploymentValues { | |||
| Assert-SafeNameValue -Name 'SiteName' -Value $SiteName | |||
| Assert-SafeNameValue -Name 'AppPoolName' -Value $AppPoolName | |||
| if ($DeployRoot -match '(?i)schedulicious') { | |||
| throw 'DeployRoot must not reference Schedulicious.' | |||
| } | |||
| if ([string]::IsNullOrWhiteSpace($BindingIpAddress)) { | |||
| throw 'BindingIpAddress must not be empty.' | |||
| } | |||
| $parsedAddress = $null | |||
| if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) { | |||
| throw "BindingIpAddress is not a valid IP address: $BindingIpAddress" | |||
| } | |||
| if ($HostHeader -match '[:/\\]') { | |||
| throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.' | |||
| } | |||
| if ($HostHeader -match '(?i)schedulicious') { | |||
| throw 'HostHeader must not reference Schedulicious.' | |||
| } | |||
| } | |||
| function Copy-ReleaseSource { | |||
| param( | |||
| [string]$From, | |||
| [string]$To | |||
| ) | |||
| param([string]$From, [string]$To) | |||
| New-Item -ItemType Directory -Force -Path $To | Out-Null | |||
| $excludedNames = @('.git', '.deployment', 'releases') | |||
| Get-ChildItem -LiteralPath $From -Force | ForEach-Object { | |||
| if ($excludedNames -notcontains $_.Name) { | |||
| Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force | |||
| $packageRoots = @(Get-ChildItem -LiteralPath $From -Force | Where-Object { $excludedNames -notcontains $_.Name }) | |||
| $unsafeSourceItem = $packageRoots | | |||
| ForEach-Object { | |||
| if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $_ } | |||
| elseif ($_.PSIsContainer) { Get-ChildItem -LiteralPath $_.FullName -Recurse -Force } | |||
| } | | |||
| Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | | |||
| Select-Object -First 1 | |||
| if ($null -ne $unsafeSourceItem) { | |||
| throw "Source contains a reparse point/symbolic link, which is not package-safe: $($unsafeSourceItem.FullName)" | |||
| } | |||
| New-Item -ItemType Directory -Force -Path $To | Out-Null | |||
| $packageRoots | ForEach-Object { | |||
| if ($excludedNames -contains $_.Name) { return } | |||
| if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { | |||
| throw "Source contains a reparse point/symbolic link, which is not package-safe: $($_.FullName)" | |||
| } | |||
| Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force | |||
| } | |||
| } | |||
| $SourcePath = [System.IO.Path]::GetFullPath($SourcePath) | |||
| $installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' | |||
| if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { | |||
| throw "Host installer is missing: $installerPath" | |||
| $unsafeEntry = Get-ChildItem -LiteralPath $To -Recurse -Force | | |||
| Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | | |||
| Select-Object -First 1 | |||
| if ($null -ne $unsafeEntry) { | |||
| throw "Package staging contains a reparse point/symbolic link: $($unsafeEntry.FullName)" | |||
| } | |||
| } | |||
| $requiredSourceFiles = @( | |||
| 'public\Default.asp', | |||
| 'public\web.config', | |||
| 'core\autoload_core.asp', | |||
| 'app\controllers\autoload_controllers.asp' | |||
| ) | |||
| foreach ($relativePath in $requiredSourceFiles) { | |||
| if (-not (Test-Path -LiteralPath (Join-Path $SourcePath $relativePath) -PathType Leaf)) { | |||
| throw "Source tree is incomplete; missing $relativePath" | |||
| function Assert-ReleaseLayout { | |||
| param([string]$Root) | |||
| $required = @( | |||
| 'public\Default.asp', | |||
| 'public\web.config', | |||
| 'core\autoload_core.asp', | |||
| 'app\controllers\autoload_controllers.asp', | |||
| 'scripts\install-iis-release.ps1' | |||
| ) | |||
| foreach ($relativePath in $required) { | |||
| if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath) -PathType Leaf)) { | |||
| throw "Package/source tree is incomplete; missing $relativePath" | |||
| } | |||
| } | |||
| foreach ($xmlFile in Get-ChildItem -LiteralPath $Root -Recurse -Force -Filter 'web.config') { | |||
| try { | |||
| [xml](Get-Content -LiteralPath $xmlFile.FullName -Raw) | Out-Null | |||
| } catch { | |||
| throw "$($xmlFile.FullName) is not valid XML: $($_.Exception.Message)" | |||
| } | |||
| } | |||
| } | |||
| try { | |||
| [xml](Get-Content -LiteralPath (Join-Path $SourcePath 'public\web.config') -Raw) | Out-Null | |||
| } catch { | |||
| throw "Source public\web.config is not valid XML: $($_.Exception.Message)" | |||
| } | |||
| Assert-SafeDeploymentValues | |||
| $SourcePath = [System.IO.Path]::GetFullPath($SourcePath) | |||
| $installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' | |||
| Assert-ReleaseLayout -Root $SourcePath | |||
| $directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git') | |||
| $commit = 'nogit' | |||
| if ($directGitRoot) { | |||
| Assert-Command 'git' | |||
| $branch = (& git -C $SourcePath branch --show-current).Trim() | |||
| if ($LASTEXITCODE -ne 0) { | |||
| throw 'Could not determine the Git branch.' | |||
| } | |||
| if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git branch.' } | |||
| if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { | |||
| throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'. Use -AllowAnyBranch only for an intentional exception." | |||
| throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'." | |||
| } | |||
| $dirty = & git -C $SourcePath status --porcelain | |||
| if ($LASTEXITCODE -ne 0) { | |||
| throw 'Could not inspect the Git worktree.' | |||
| } | |||
| if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the Git worktree.' } | |||
| if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { | |||
| throw 'Refusing to deploy a dirty worktree. Commit/stash changes or use -AllowDirty for an intentional, auditable exception.' | |||
| throw 'Refusing to deploy a dirty worktree. Use -AllowDirty only for a reviewed exception.' | |||
| } | |||
| $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() | |||
| if ($LASTEXITCODE -ne 0) { | |||
| throw 'Could not determine the Git commit.' | |||
| } | |||
| if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git commit.' } | |||
| Write-Host "Source branch: $branch" | |||
| Write-Host "Source commit: $commit" | |||
| } else { | |||
| Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' | |||
| if (-not $AllowAnyBranch) { | |||
| throw 'Use a standalone CI checkout, or pass -AllowAnyBranch explicitly for a reviewed non-Git source tree.' | |||
| throw 'Use a standalone checkout, or pass -AllowAnyBranch for a reviewed non-Git source tree.' | |||
| } | |||
| } | |||
| if ([string]::IsNullOrWhiteSpace($ReleaseId)) { | |||
| $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit | |||
| } | |||
| if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { | |||
| throw 'ReleaseId may contain only letters, numbers, dot, underscore, and hyphen.' | |||
| } | |||
| if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { | |||
| throw '-Rollback requires -RollbackTo <release-id>.' | |||
| if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { throw 'ReleaseId contains unsupported characters.' } | |||
| if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-Rollback requires -RollbackTo.' } | |||
| if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-RollbackTo requires -Rollback.' } | |||
| if ($RunMigrations -and $Rollback) { throw '-RunMigrations is not valid during rollback.' } | |||
| if (($PreflightOnly -or $LocalPreflightOnly) -and $Rollback) { | |||
| throw 'Preflight modes cannot be combined with -Rollback.' | |||
| } | |||
| if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { | |||
| throw '-RollbackTo is only valid with -Rollback.' | |||
| } | |||
| if ($RunMigrations -and $Rollback) { | |||
| throw '-RunMigrations is not valid during rollback.' | |||
| if ($PreflightOnly -and $LocalPreflightOnly) { | |||
| throw '-PreflightOnly and -LocalPreflightOnly are mutually exclusive.' | |||
| } | |||
| $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) | |||
| $packageStage = Join-Path $workRoot 'package' | |||
| $packageExtract = Join-Path $workRoot 'verify' | |||
| $packagePath = Join-Path $workRoot ($ReleaseId + '.zip') | |||
| $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId | |||
| $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' | |||
| $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' | |||
| try { | |||
| if (-not $Rollback -and -not $RemotePreflightOnly) { | |||
| Write-Step 'Staging the full repository for packaging' | |||
| $sha256 = '' | |||
| if (-not $Rollback) { | |||
| Write-Step 'Staging the complete repository for packaging' | |||
| Copy-ReleaseSource -From $SourcePath -To $packageStage | |||
| Assert-ReleaseLayout -Root $packageStage | |||
| Add-Type -AssemblyName System.IO.Compression.FileSystem | |||
| [System.IO.Compression.ZipFile]::CreateFromDirectory( | |||
| $packageStage, | |||
| $packagePath, | |||
| [System.IO.Compression.CompressionLevel]::Optimal, | |||
| $false | |||
| ) | |||
| [System.IO.Compression.ZipFile]::CreateFromDirectory($packageStage, $packagePath, [System.IO.Compression.CompressionLevel]::Optimal, $false) | |||
| [System.IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $packageExtract) | |||
| Assert-ReleaseLayout -Root $packageExtract | |||
| $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash | |||
| Write-Host "Package: $packagePath" | |||
| Write-Host "SHA-256: $sha256" | |||
| } else { | |||
| $sha256 = '' | |||
| } | |||
| if ($LocalPreflightOnly) { | |||
| Write-Step 'Local/controller preflight passed; no network connection or host change was made' | |||
| exit 0 | |||
| } | |||
| $remoteArguments = New-Object 'System.Collections.Generic.List[string]' | |||
| $remoteArguments.Add('&') | |||
| $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-SiteName' -Value $SiteName | |||
| Add-RemoteArgument $remoteArguments '-SiteName' $SiteName | |||
| Add-RemoteArgument $remoteArguments '-AppPoolName' $AppPoolName | |||
| Add-RemoteArgument $remoteArguments '-DeployRoot' $DeployRoot | |||
| Add-RemoteArgument $remoteArguments '-BindingIpAddress' $BindingIpAddress | |||
| Add-RemoteArgument $remoteArguments '-BindingPort' $BindingPort.ToString() | |||
| Add-RemoteArgument $remoteArguments '-HostHeader' $HostHeader | |||
| Add-RemoteArgument $remoteArguments '-KeepReleases' $KeepReleases.ToString() | |||
| if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { Add-RemoteArgument $remoteArguments '-InitialWebConfigPath' $InitialWebConfigPath } | |||
| if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { Add-RemoteArgument $remoteArguments '-BaseUrl' $BaseUrl } | |||
| if ($Rollback) { | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-RollbackTo' -Value $RollbackTo | |||
| } elseif (-not $RemotePreflightOnly) { | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-PackagePath' -Value $remotePackage | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-ReleaseId' -Value $ReleaseId | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-ExpectedSha256' -Value $sha256 | |||
| } else { | |||
| Add-RemoteArgument $remoteArguments '-RollbackTo' $RollbackTo | |||
| } elseif ($PreflightOnly) { | |||
| $remoteArguments.Add('-PreflightOnly') | |||
| } else { | |||
| Add-RemoteArgument $remoteArguments '-PackagePath' $remotePackage | |||
| Add-RemoteArgument $remoteArguments '-ReleaseId' $ReleaseId | |||
| Add-RemoteArgument $remoteArguments '-ExpectedSha256' $sha256 | |||
| } | |||
| if (-not [string]::IsNullOrWhiteSpace($DeployRoot)) { | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-DeployRoot' -Value $DeployRoot | |||
| } | |||
| if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-BaseUrl' -Value $BaseUrl | |||
| } | |||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-KeepReleases' -Value $KeepReleases.ToString() | |||
| if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } | |||
| if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } | |||
| $remoteScript = $remoteArguments -join ' ' | |||
| $remoteBytes = [Text.Encoding]::Unicode.GetBytes($remoteScript) | |||
| $remoteEncodedCommand = [Convert]::ToBase64String($remoteBytes) | |||
| $remoteEncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) | |||
| $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand | |||
| Write-Host "Remote target: $RemoteTarget (Tailscale/OpenSSH port $RemotePort)" | |||
| if ($DryRun) { | |||
| Write-Step 'Dry-run complete; no network connection was made' | |||
| Write-Host "Would create remote directory: $remoteDirectory" | |||
| Write-Host "Would copy installer: $installerPath" | |||
| if (-not $Rollback -and -not $RemotePreflightOnly) { | |||
| Write-Host "Would copy package: $packagePath" | |||
| Write-Host "Would use dedicated site '$SiteName' and app pool '$AppPoolName'." | |||
| Write-Host "Would use binding ${BindingIpAddress}:$BindingPort with host header '$HostHeader'." | |||
| if ($PreflightOnly) { | |||
| Write-Host 'Would stream the installer over SSH for a read-only host preflight; no remote file would be written.' | |||
| } else { | |||
| Write-Host "Would create remote directory: $remoteDirectory" | |||
| Write-Host "Would copy installer: $installerPath" | |||
| if (-not $Rollback) { Write-Host "Would copy package: $packagePath" } | |||
| Write-Host ('Would execute host script: ' + $remoteScript) | |||
| } | |||
| Write-Host ('Would execute host script: ' + $remoteScript) | |||
| Write-Host ('Transport command uses PowerShell -EncodedCommand to avoid remote-shell quoting ambiguity.') | |||
| exit 0 | |||
| } | |||
| Assert-Command $SshExe | |||
| Assert-Command $ScpExe | |||
| if ($PreflightOnly) { | |||
| Write-Step 'Streaming the installer for read-only host preflight' | |||
| $installerSource = Get-Content -LiteralPath $installerPath -Raw | |||
| $argumentTail = @($remoteArguments | Select-Object -Skip 2) -join ' ' | |||
| $stdinScript = "& {`r`n" + $installerSource + "`r`n} " + $argumentTail | |||
| $stdinScript | & $SshExe -p $RemotePort $RemoteTarget 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -Command -' | |||
| if ($LASTEXITCODE -ne 0) { throw "Remote preflight failed with exit code $LASTEXITCODE." } | |||
| Write-Step 'Remote host preflight completed successfully without persistent host changes' | |||
| exit 0 | |||
| } | |||
| Write-Step 'Creating remote staging directory over Tailscale/OpenSSH' | |||
| Assert-Command $ScpExe | |||
| Write-Step 'Creating remote temporary directory' | |||
| $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" | |||
| $mkdirBytes = [Text.Encoding]::Unicode.GetBytes($mkdirScript) | |||
| $mkdirEncodedCommand = [Convert]::ToBase64String($mkdirBytes) | |||
| & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncodedCommand) | |||
| $mkdirEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($mkdirScript)) | |||
| & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncoded) | |||
| if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } | |||
| Write-Step 'Copying the host installer' | |||
| & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') | |||
| if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } | |||
| if (-not $Rollback -and -not $RemotePreflightOnly) { | |||
| if (-not $Rollback) { | |||
| Write-Step 'Copying the release package' | |||
| & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') | |||
| if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } | |||
| @@ -251,7 +309,6 @@ try { | |||
| Write-Step 'Invoking the host-side installer' | |||
| & $SshExe -p $RemotePort $RemoteTarget $remoteCommand | |||
| if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } | |||
| Write-Step 'Remote operation completed successfully' | |||
| } finally { | |||
| if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { | |||
| @@ -259,7 +316,5 @@ try { | |||
| Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force | |||
| Write-Host "Package retained at $keptPath" | |||
| } | |||
| if (Test-Path -LiteralPath $workRoot) { | |||
| Remove-Item -LiteralPath $workRoot -Recurse -Force | |||
| } | |||
| if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force } | |||
| } | |||
Powered by TurnKey Linux.