param( [string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"), [string]$Password, [switch]$SkipRecycle, [switch]$SkipMigrations ) <# .SYNOPSIS Publish the app and mirror output to an FTP-deployed IIS site. .DESCRIPTION - Reads environment-specific settings from a PowerShell data file profile. - Builds with dotnet publish. - Uses WinSCP to mirror publish output into remote directory (deletes extraneous files). - Optionally recycles IIS app pool and runs EF migrations remotely over WinRM. .EXAMPLE pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1 #> Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Assert-Tool { param([Parameter(Mandatory = $true)][string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required tool '$Name' not found. Install it or update your deploy profile." } } function Require-ConfigValue { param( [Parameter(Mandatory = $true)][hashtable]$Config, [Parameter(Mandatory = $true)][string]$Key ) if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) { throw "Missing required deploy profile value '$Key'." } } function Resolve-ProfilePath { param( [Parameter(Mandatory = $true)][string]$BaseDirectory, [Parameter(Mandatory = $true)][string]$PathValue ) $expanded = [Environment]::ExpandEnvironmentVariables($PathValue) if ([System.IO.Path]::IsPathRooted($expanded)) { return $expanded } return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded)) } function Read-PlainOrPrompt { param( [string]$Value, [Parameter(Mandatory = $true)][string]$Prompt, [bool]$Secure = $false ) if (-not [string]::IsNullOrWhiteSpace($Value)) { return $Value } if ($Secure) { $pwd = Read-Host -Prompt $Prompt -AsSecureString $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd) try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) } finally { if ($ptr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } } return Read-Host -Prompt $Prompt } function Invoke-WinRmScript { param( [Parameter(Mandatory = $true)][hashtable]$Config, [Parameter(Mandatory = $true)][string]$PasswordValue, [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock, [object[]]$ArgumentList = @() ) Require-ConfigValue $Config "WinRmComputer" Require-ConfigValue $Config "WinRmCredentialUser" $secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force $cred = New-Object pscredential($Config.WinRmCredentialUser, $secure) $invokeParams = @{ ComputerName = $Config.WinRmComputer Credential = $cred ScriptBlock = $ScriptBlock ArgumentList = $ArgumentList } if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) { $invokeParams["UseSSL"] = $true } if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) { $invokeParams["Authentication"] = [string]$Config.WinRmAuth } Invoke-Command @invokeParams } if (-not (Test-Path $ProfilePath)) { throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values." } $resolvedProfilePath = (Resolve-Path $ProfilePath).Path $profileDirectory = Split-Path -Parent $resolvedProfilePath $config = Import-PowerShellDataFile -Path $resolvedProfilePath Require-ConfigValue $config "ProjectPath" Require-ConfigValue $config "Configuration" Require-ConfigValue $config "Runtime" Require-ConfigValue $config "PublishDir" Require-ConfigValue $config "WinScpPath" Require-ConfigValue $config "RemoteDir" $winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" } $useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName) if (-not $useStoredSession) { Require-ConfigValue $config "FtpHost" Require-ConfigValue $config "FtpUser" } $projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath) $publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir) $winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath) $selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false } $recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false } $runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false } $recycleAppPool = $recycleAppPool -and -not $SkipRecycle $runEfMigrations = $runEfMigrations -and -not $SkipMigrations $passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD $passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv } $needsFtpPassword = -not $useStoredSession $needsWinRmPassword = $recycleAppPool -or $runEfMigrations $sharedPassword = "" if ($needsFtpPassword -or $needsWinRmPassword) { $prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" } $sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true } $passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" } $passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" } Assert-Tool "dotnet" Assert-Tool $winScpPath Write-Host "1) Publishing..." -ForegroundColor Cyan if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Force -Path $publishDir | Out-Null $publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir) if (-not $selfContained) { $publishArgs += "--self-contained=false" } dotnet @publishArgs Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan if ($recycleAppPool) { Require-ConfigValue $config "AppPoolName" $appPoolName = [string]$config.AppPoolName Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan try { Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { param($poolName) Import-Module WebAdministration Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue } -ArgumentList @($appPoolName) } catch { Write-Warning "WinRM stop failed: $($_.Exception.Message)." } } Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan $openCommand = if ($useStoredSession) { "open `"$winScpSessionName`"" } else { $ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser) $ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", "")) $ftpHost = [string]$config.FtpHost "open ftp://$ftpUser`:$ftpPassword@$ftpHost" } $tempScript = New-TemporaryFile @( "option batch continue" "option confirm off" $openCommand "lcd `"$publishDir`"" "cd $([string]$config.RemoteDir)" "synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`"" "exit" ) | Set-Content -Path $tempScript -Encoding UTF8 & $winScpPath "/ini=nul" "/script=$tempScript" Remove-Item $tempScript -ErrorAction SilentlyContinue if ($recycleAppPool) { Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan try { Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { param($poolName) Import-Module WebAdministration Start-WebAppPool -Name $poolName } -ArgumentList @($appPoolName) } catch { Write-Warning "WinRM start failed: $($_.Exception.Message)." } } if ($runEfMigrations) { Require-ConfigValue $config "RemoteSitePath" Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan try { Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { param($sitePath) Set-Location $sitePath if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { throw "dotnet is not available on remote host." } dotnet ef database update --no-build } -ArgumentList @([string]$config.RemoteSitePath) } catch { Write-Warning "WinRM migrations failed: $($_.Exception.Message)." } } Write-Host "Done." -ForegroundColor Green