PwshSetupCore.ps1
|
# PwshSetupCore.ps1 0.0.1, built from src by Publish-SetupModule.ps1. Do not edit this file. #region Core.psm1 <# .SYNOPSIS Internal: the context a run shares across its parts, the data folders, the log file, markup escaping and durations. #> $script:SetupDataRoot = Join-Path -Path $env:ProgramData -ChildPath 'PwshSetup' # StepPath is the steps folder being loaded, so Use-SetupTemplate finds the templates beside it. # Task is the Invoke-AnsiTask control of the running step, so Write-SetupLog can print under it. # RestartRequest is 'After' or 'Retry', set by Request-SetupRestart while a step runs. $script:SetupContext = [pscustomobject]@{ StepPath = $null LogFile = $null Task = $null StepId = $null RestartRequest = $null } # ── Context ────────────────────────────────────────────────────────────────── function Get-SetupContext { [CmdletBinding()] [OutputType([pscustomobject])] param() return $script:SetupContext } function Reset-SetupContext { [CmdletBinding()] param() $script:SetupContext.StepPath = $null $script:SetupContext.LogFile = $null $script:SetupContext.Task = $null $script:SetupContext.StepId = $null $script:SetupContext.RestartRequest = $null } function Test-SetupInteractive { [CmdletBinding()] [OutputType([bool])] param() try { return -not ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) } catch { return $false } } # ── Paths ──────────────────────────────────────────────────────────────────── function Get-SetupDataPath { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [ValidateSet('Logs', 'State')] [string] $Name ) return (Join-Path -Path $script:SetupDataRoot -ChildPath $Name) } # The built module keeps the templates in Templates beside it; the source tree keeps them in templates beside src. function Get-SetupTemplateRoot { [CmdletBinding()] [OutputType([string])] param() $candidates = @( (Join-Path -Path $PSScriptRoot -ChildPath 'Templates') (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'templates') ) foreach ($candidate in $candidates) { if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate } } throw ('No templates folder found beside {0}.' -f $PSScriptRoot) } function Get-SetupOwnTemplateRoot { # Your own templates: the templates folder beside the steps folder, or nothing when there is none. [CmdletBinding()] [OutputType([string])] param( [string] $StepPath = $script:SetupContext.StepPath ) if (-not $StepPath) { return $null } $root = Join-Path -Path (Split-Path -Path $StepPath -Parent) -ChildPath 'templates' if (Test-Path -LiteralPath $root -PathType Container) { return $root } return $null } # ── Log ────────────────────────────────────────────────────────────────────── function Write-SetupLogFile { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Message, [ValidateSet('INFO', 'WARN', 'ERROR', 'OUTPUT')] [string] $Level = 'INFO' ) $file = $script:SetupContext.LogFile if (-not $file) { return } $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture) $step = '-' if ($script:SetupContext.StepId) { $step = $script:SetupContext.StepId } $lines = foreach ($line in ($Message.TrimEnd() -split '\r?\n')) { '{0} {1,-6} {2} {3}' -f $stamp, $Level, $step, $line } [System.IO.File]::AppendAllLines($file, [string[]]@($lines)) } # ── Text ───────────────────────────────────────────────────────────────────── function ConvertTo-SetupLiteral { # Brackets doubled, so PwshAnsi markup prints the text as typed. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] [string] $Text ) return (([string]$Text -replace '\[', '[[') -replace '\]', ']]') } function Format-SetupDuration { # 0.4s, 2.1s, 1m 05s, 1h 05m, the same on every culture. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [TimeSpan] $Duration ) $culture = [cultureinfo]::InvariantCulture if ($Duration.TotalSeconds -lt 60) { return [string]::Format($culture, '{0:0.0}s', $Duration.TotalSeconds) } if ($Duration.TotalMinutes -lt 60) { return [string]::Format($culture, '{0}m {1:00}s', [Math]::Floor($Duration.TotalMinutes), $Duration.Seconds) } return [string]::Format($culture, '{0}h {1:00}m', [Math]::Floor($Duration.TotalHours), $Duration.Minutes) } #endregion Core.psm1 #region Steps.psm1 <# .SYNOPSIS Internal: turns a folder of step files into ordered step objects, runs a step's Test, and reads what a step installs. .DESCRIPTION Every .ps1 in the folder is a step, ordered by the number its name starts with. Step files and templates each run in a module of their own, so the values they set at the top, or take as parameters, stay reachable from their Test and Action when those run later. Inside both, $ErrorActionPreference is Stop, and progress bars are off: they would draw over the run's own progress lines. #> # ── Loading ────────────────────────────────────────────────────────────────── function Import-SetupStep { [CmdletBinding()] [OutputType([pscustomobject[]])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path ) (Get-SetupContext).StepPath = $Path $files = Get-ChildItem -LiteralPath $Path -Filter '*.ps1' -File $steps = foreach ($file in $files) { Import-SetupStepFile -File $file } return @(@($steps) | Sort-Object -Property Order, Id) } function Import-SetupStepFile { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [System.IO.FileInfo] $File ) try { $step = Import-SetupScript -Path $File.FullName } catch { throw ('Step file {0}: {1}' -f $File.Name, $_.Exception.Message) } $step.Id = $File.BaseName $step.Order = [int]::MaxValue if ($File.Name -match '^(?<order>\d+)') { $step.Order = [int]$Matches.order } $step.File = $File.FullName return $step } function Import-SetupScript { # Runs a step file or a template in a module of its own and returns the one step it describes. A step that came # through Use-SetupTemplate keeps the template's module. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Path, [hashtable] $Parameter = @{} ) $loader = { param($SetupScriptPath, $SetupScriptParameter) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $script:SetupScriptOutput = @(. $SetupScriptPath @SetupScriptParameter) } $moduleName = 'PwshSetup.Step.{0}' -f [System.IO.Path]::GetFileNameWithoutExtension($Path) $module = New-Module -Name $moduleName -ScriptBlock $loader -ArgumentList $Path, $Parameter $output = @(& $module { $script:SetupScriptOutput }) $found = @($output | Where-Object -FilterScript { $_ -is [psobject] -and $_.PSObject.TypeNames -contains 'PwshSetup.Step' }) if ($found.Count -ne 1) { throw ('it returned {0} steps, and a step file returns exactly one New-SetupStep or Use-SetupTemplate.' -f $found.Count) } $step = $found[0] if (-not $step.Module) { $step.Module = $module } return $step } # ── Checks ─────────────────────────────────────────────────────────────────── function Test-SetupStepDone { # The last thing the Test writes decides, by PowerShell truthiness, so a -match that finds nothing reads as not done. # A Test that throws counts as not done too, and Error says why: often it checks what an earlier step installs. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [pscustomobject] $Step ) if (-not $Step.Test) { return [pscustomobject]@{ Done = $false; Error = $null } } try { $output = @(& $Step.Test 3>$null 6>$null) $done = $output.Count -gt 0 -and [bool]$output[-1] return [pscustomobject]@{ Done = $done; Error = $null } } catch { return [pscustomobject]@{ Done = $false; Error = $_.Exception.Message } } } function Get-SetupStepPackage { # The Chocolatey packages a step's Action names, read from its syntax tree without running it. A name in a variable # set at the top of the file, or passed to a template, is read from the step's module; one set inside the Action # cannot be known before it runs, and Unknown counts it. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [pscustomobject] $Step ) $isInstall = { param($Node) $Node -is [System.Management.Automation.Language.CommandAst] -and $Node.GetCommandName() -eq 'Install-ChocoPackage' } $names = [System.Collections.Generic.List[string]]::new() $unknown = 0 foreach ($command in $Step.Action.Ast.FindAll($isInstall, $true)) { $value = Get-SetupNameArgument -Command $command $name = $null if ($value -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $name = $value.Value } elseif ($value -is [System.Management.Automation.Language.VariableExpressionAst] -and $Step.Module) { $known = $Step.Module.SessionState.PSVariable.GetValue($value.VariablePath.UserPath) if ($known -is [string]) { $name = $known } } if ($name) { $names.Add($name) } else { $unknown++ } } return [pscustomobject]@{ Names = $names.ToArray(); Unknown = $unknown } } function Get-SetupNameArgument { # The value given to -Name, or the first positional argument. [CmdletBinding()] [OutputType([System.Management.Automation.Language.Ast])] param( [Parameter(Mandatory)] [System.Management.Automation.Language.CommandAst] $Command ) $elements = $Command.CommandElements for ($index = 1; $index -lt $elements.Count; $index++) { $element = $elements[$index] if ($element -isnot [System.Management.Automation.Language.CommandParameterAst]) { return $element } if ($element.ParameterName -eq 'Name') { if ($element.Argument) { return $element.Argument } if ($index + 1 -lt $elements.Count) { return $elements[$index + 1] } return $null } if (-not $element.Argument -and $element.ParameterName -ne 'PassThru') { $index++ } } return $null } #endregion Steps.psm1 #region State.psm1 <# .SYNOPSIS Internal: what a run remembers across restarts. .DESCRIPTION One JSON file per step folder under %ProgramData%\PwshSetup\State, and beside it the script parameters the resume task passes back. Written through a temp file, so a restart mid-write cannot leave half a file behind. #> # ── Files ──────────────────────────────────────────────────────────────────── function Get-SetupStateFile { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $StepPath, [ValidateSet('State', 'Arguments')] [string] $Kind = 'State' ) $bytes = [System.Text.Encoding]::UTF8.GetBytes($StepPath.ToLowerInvariant()) $hash = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($bytes)).Substring(0, 16).ToLowerInvariant() $extension = 'json' if ($Kind -eq 'Arguments') { $extension = 'args.clixml' } return (Join-Path -Path (Get-SetupDataPath -Name 'State') -ChildPath ('{0}.{1}' -f $hash, $extension)) } function New-SetupFolder { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Path ) if (-not (Test-Path -LiteralPath $Path)) { $null = New-Item -ItemType Directory -Path $Path -Force } } # ── State ──────────────────────────────────────────────────────────────────── function New-SetupState { [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string] $StepPath, [AllowEmptyString()] [string] $ScriptPath = '', [Parameter(Mandatory)] [string] $LogFile, [Parameter(Mandatory)] [string[]] $Selected ) return @{ StepPath = $StepPath ScriptPath = $ScriptPath LogFile = $LogFile StartedAt = (Get-Date).ToUniversalTime().ToString('o') Selected = @($Selected) Results = @{} Retries = @{} Restarts = 0 RestartingAt = $null } } function Read-SetupState { [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string] $StepPath ) $file = Get-SetupStateFile -StepPath $StepPath if (-not (Test-Path -LiteralPath $file)) { return $null } try { $state = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json -AsHashtable } catch { return $null } if (-not $state -or $state.StepPath -ne $StepPath) { return $null } $state.Selected = @($state.Selected) if ($null -eq $state.Results) { $state.Results = @{} } if ($null -eq $state.Retries) { $state.Retries = @{} } return $state } function Save-SetupState { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable] $State ) $file = Get-SetupStateFile -StepPath $State.StepPath New-SetupFolder -Path (Split-Path -Path $file -Parent) $temp = '{0}.tmp' -f $file $State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $temp -Encoding utf8NoBOM Move-Item -LiteralPath $temp -Destination $file -Force } function Remove-SetupState { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $StepPath ) foreach ($kind in 'State', 'Arguments') { Remove-Item -LiteralPath (Get-SetupStateFile -StepPath $StepPath -Kind $kind) -Force -ErrorAction Ignore } } function Save-SetupArgument { # Switches become booleans: a SwitchParameter does not come back from Import-Clixml as one. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $StepPath, [AllowNull()] [hashtable] $Arguments ) $portable = @{} if ($Arguments) { foreach ($key in $Arguments.Keys) { $value = $Arguments[$key] if ($value -is [System.Management.Automation.SwitchParameter]) { $value = $value.IsPresent } $portable[$key] = $value } } $file = Get-SetupStateFile -StepPath $StepPath -Kind 'Arguments' New-SetupFolder -Path (Split-Path -Path $file -Parent) $portable | Export-Clixml -LiteralPath $file return $file } function Set-SetupStepResult { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable] $State, [Parameter(Mandatory)] [string] $Id, [Parameter(Mandatory)] [ValidateSet('Done', 'Skipped', 'Failed', 'Retry')] [string] $Status, [TimeSpan] $Duration = [TimeSpan]::Zero, [AllowEmptyString()] [string] $Reason = '' ) $State.Results[$Id] = @{ Status = $Status Seconds = [Math]::Round($Duration.TotalSeconds, 1) Reason = $Reason } } # ── Restarts ───────────────────────────────────────────────────────────────── function ConvertTo-SetupUtc { # ConvertFrom-Json hands ISO dates back as DateTime; a string is parsed the same way. [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Mandatory)] [object] $Value ) if ($Value -is [datetime]) { return $Value.ToUniversalTime() } $styles = [System.Globalization.DateTimeStyles]::RoundtripKind return [datetime]::Parse([string]$Value, [cultureinfo]::InvariantCulture, $styles).ToUniversalTime() } function Test-SetupRestartedSince { # The run asked for a restart, and the machine has booted since. [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [hashtable] $State ) if (-not $State.RestartingAt) { return $false } $restartingAt = ConvertTo-SetupUtc -Value $State.RestartingAt $bootedAt = (Get-CimInstance -ClassName 'Win32_OperatingSystem').LastBootUpTime.ToUniversalTime() return ($bootedAt -gt $restartingAt) } #endregion State.psm1 #region Restart.psm1 <# .SYNOPSIS Internal: restarts that resume. .DESCRIPTION Before restarting, the run registers a scheduled task that starts its script again at the next sign-in of the same user, elevated and on that user's desktop. The resumed run removes the task first thing. #> $script:ResumeTaskName = 'Resume' $script:ResumeTaskPath = '\PwshSetup\' $script:RestartCountdownSeconds = 10 function Register-SetupResume { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $ScriptPath, [Parameter(Mandatory)] [string] $ArgumentFile ) $quote = { param($Text) "'" + ($Text -replace "'", "''") + "'" } $command = @( '$env:PWSHSETUP_RERUN = ''resume''' ('Set-Location -LiteralPath {0}' -f (& $quote (Split-Path -Path $ScriptPath -Parent))) ('$p = Import-Clixml -LiteralPath {0}' -f (& $quote $ArgumentFile)) ('& {0} @p' -f (& $quote $ScriptPath)) 'exit $LASTEXITCODE' ) -join "`n" $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) $pwsh = (Get-Process -Id $PID).Path $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $actionArguments = @{ Execute = $pwsh Argument = ('-NoProfile -ExecutionPolicy Bypass -EncodedCommand {0}' -f $encoded) } $settingsArguments = @{ AllowStartIfOnBatteries = $true DontStopIfGoingOnBatteries = $true ExecutionTimeLimit = [TimeSpan]::Zero MultipleInstances = 'IgnoreNew' } $taskArguments = @{ TaskName = $script:ResumeTaskName TaskPath = $script:ResumeTaskPath Action = New-ScheduledTaskAction @actionArguments Trigger = New-ScheduledTaskTrigger -AtLogOn -User $user Principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest Settings = New-ScheduledTaskSettingsSet @settingsArguments Force = $true ErrorAction = 'Stop' } $null = Register-ScheduledTask @taskArguments Write-SetupLogFile -Message ('Registered the resume task for {0}.' -f $user) } function Unregister-SetupResume { [CmdletBinding()] param() $task = Get-ScheduledTask -TaskName $script:ResumeTaskName -TaskPath $script:ResumeTaskPath -ErrorAction Ignore if ($task) { Unregister-ScheduledTask -TaskName $script:ResumeTaskName -TaskPath $script:ResumeTaskPath -Confirm:$false } } function Restart-SetupComputer { # $true when the restart goes ahead, $false when Esc cancels it. With nobody at the keyboard the countdown runs out # and the restart goes ahead. [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string] $StepName ) $stepText = ConvertTo-SetupLiteral -Text $StepName $message = '[BrightYellow]{0} needs a restart.[/] [DarkGray]Setup carries on after the next sign-in.[/]' -f $stepText Format-AnsiText -Message $message | Out-AnsiHost if (Test-SetupInteractive) { $pauseArguments = @{ Message = 'Restarting. Any key restarts now, Esc cancels' TimeoutSeconds = $script:RestartCountdownSeconds ShowCountdown = $true KeepMessage = $true MessageColor = 'BrightYellow' } $answer = Read-AnsiPause @pauseArguments if ($false -eq $answer) { Write-SetupLogFile -Message 'Restart cancelled.' -Level 'WARN' return $false } } Write-SetupLogFile -Message 'Restarting.' # shutdown.exe over Restart-Computer: the latter's WMI/CIM call has been seen to time out on fresh # VMs even when the system is otherwise responsive. shutdown.exe fires a straight local syscall and # returns immediately, so the current process stays live just long enough to log the request. & shutdown.exe /r /t 0 /f if ($LASTEXITCODE -ne 0) { throw ('shutdown.exe exited with code {0} while requesting restart.' -f $LASTEXITCODE) } return $true } #endregion Restart.psm1 #region Shell.psm1 <# .SYNOPSIS Internal: the screens of a run, drawn with PwshAnsi. Header, key line and keys, the step list, the dry run, the summary, the failure. #> $script:ResultLabel = @{ Done = '[BrightGreen]Done[/]' Skipped = '[DarkGray]Already done[/]' Failed = '[BrightRed]Failed[/]' Retry = '[BrightYellow]After restart[/]' NotRun = '[DarkGray]Not run[/]' } $script:ReasonLength = 60 $script:ResizeSettleMilliseconds = 150 $script:KeyPollMilliseconds = 40 $script:TableArguments = @{ Border = 'Rounded' BorderColor = 'DarkGray' HeaderColor = 'BrightCyan' ShowRowSeparators = $true } # ── Screen ─────────────────────────────────────────────────────────────────── function Clear-SetupScreen { [CmdletBinding()] param() if (-not (Test-SetupInteractive)) { return } Clear-Host [Console]::Write(('{0}[3J' -f [char]27)) } function Write-SetupBlankLine { [CmdletBinding()] param() [Console]::WriteLine() } function Write-SetupHeader { # Label and value pairs, one to a row. Values may carry markup. [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Item ) Format-AnsiRule -Title '[BrightWhite]PwshSetup[/]' -Alignment Left -LineColor DarkGray | Out-AnsiHost if ($Item.Count -gt 0) { $width = ($Item | ForEach-Object -Process { ([string]$_[0]).Length } | Measure-Object -Maximum).Maximum $rows = [System.Collections.Generic.List[object]]::new() foreach ($pair in $Item) { $rows.Add([string[]]@(('[DarkGray]{0}[/]' -f ([string]$pair[0]).PadRight($width)), [string]$pair[1])) } Format-AnsiGrid -Rows $rows.ToArray() -Padding 3 | Out-AnsiHost } Write-SetupBlankLine } function Write-SetupNote { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Text, [string] $Color ) $message = ConvertTo-SetupLiteral -Text $Text if ($Color) { $message = '[{0}]{1}[/]' -f $Color, $message } Format-AnsiText -Message $message | Out-AnsiHost } # ── Keys ───────────────────────────────────────────────────────────────────── function Write-SetupKeyLine { # Every entry is "<key> <label>", e.g. "F6 Run". [CmdletBinding()] param( [Parameter(Mandatory)] [string[]] $Key ) $parts = foreach ($entry in $Key) { $split = $entry -split ' ', 2 '[BrightCyan]{0}[/] {1}' -f $split[0], (ConvertTo-SetupLiteral -Text $split[1]) } Format-AnsiText -Message ($parts -join ' [DarkGray]·[/] ') | Out-AnsiHost } function Read-SetupKey { # Waits under a screen: a listed key or Esc acts at once, and a resize returns once the window keeps one size, so # the caller redraws. Action is Key, Back or Resize. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string[]] $Key ) $Host.UI.RawUI.FlushInputBuffer() $width = [Console]::WindowWidth $height = [Console]::WindowHeight $resizedAt = $null while ($true) { if (-not [Console]::KeyAvailable) { if ([Console]::WindowWidth -ne $width -or [Console]::WindowHeight -ne $height) { $width = [Console]::WindowWidth $height = [Console]::WindowHeight $resizedAt = [datetime]::UtcNow } elseif ($resizedAt -and ([datetime]::UtcNow - $resizedAt).TotalMilliseconds -ge $script:ResizeSettleMilliseconds) { return [pscustomobject]@{ Action = 'Resize'; Key = '' } } Start-Sleep -Milliseconds $script:KeyPollMilliseconds continue } $name = [string][Console]::ReadKey($true).Key if ($name -eq 'Escape') { return [pscustomobject]@{ Action = 'Back'; Key = $name } } if ($Key -contains $name) { return [pscustomobject]@{ Action = 'Key'; Key = $name } } } } function Read-SetupConfirm { # No is the default: Enter alone never starts anything. [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string] $Prompt ) $Host.UI.RawUI.FlushInputBuffer() return [bool](Read-AnsiConfirm -Prompt $Prompt -Default $false) } # ── Steps ──────────────────────────────────────────────────────────────────── function Format-SetupOrder { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [pscustomobject] $Step ) if ($Step.Order -eq [int]::MaxValue) { return '' } return ('{0:000}' -f $Step.Order) } function Format-SetupStepAction { # What a step does, read from its code: install the packages it names, or run; then restart when it says so. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [pscustomobject] $Step ) $packages = Get-SetupStepPackage -Step $Step $action = 'run' if ($packages.Names.Count -gt 0) { $action = 'install {0}' -f ($packages.Names -join ', ') if ($packages.Unknown -gt 0) { $action += ' and more' } } elseif ($packages.Unknown -gt 0) { $action = 'install packages' } $text = '[BrightCyan]{0}[/]' -f (ConvertTo-SetupLiteral -Text $action) if ($Step.RequiresRestart) { $text += ', [BrightYellow]then restart[/]' } return $text } function Write-SetupList { # The steps folder as it stands, in run order, read without running any of it. [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Steps ) $rows = foreach ($step in $Steps) { $from = '[DarkGray]own[/]' if ($step.Template) { $from = ConvertTo-SetupLiteral -Text $step.Template } [pscustomobject]@{ '#' = Format-SetupOrder -Step $step Step = ConvertTo-SetupLiteral -Text $step.Name From = $from Does = Format-SetupStepAction -Step $step } } $tableArguments = $script:TableArguments Format-AnsiTable -Data @($rows) @tableArguments | Out-AnsiHost } # ── Dry run ────────────────────────────────────────────────────────────────── function Get-SetupPlan { # What each step's Test reports, behind one progress line. Tests only look, so this changes nothing. [CmdletBinding()] [OutputType([pscustomobject[]])] param( [Parameter(Mandatory)] [object[]] $Steps ) $plan = [System.Collections.Generic.List[object]]::new() $check = { param($Task) for ($index = 0; $index -lt $Steps.Count; $index++) { $result = Test-SetupStepDone -Step $Steps[$index] $plan.Add([pscustomobject]@{ Step = $Steps[$index]; Done = $result.Done; Error = $result.Error }) $Task.Update($index + 1, $Steps.Count) } } Invoke-AnsiTask -Name ('Checking {0} steps' -f $Steps.Count) -ScriptBlock $check -ProgressShow Percent return $plan.ToArray() } function Write-SetupPlan { # The dry run: what each step would do, from its Test and its code, and a line that sums it up. [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Plan ) $rows = foreach ($row in $Plan) { $would = '[DarkGray]skip, already done[/]' if (-not $row.Done) { $would = Format-SetupStepAction -Step $row.Step } if ($row.Error) { $reason = $row.Error if ($reason.Length -gt $script:ReasonLength) { $reason = '{0}...' -f $reason.Substring(0, $script:ReasonLength) } $would += ' [DarkGray](check failed: {0})[/]' -f (ConvertTo-SetupLiteral -Text $reason) } [pscustomobject]@{ '#' = Format-SetupOrder -Step $row.Step Step = ConvertTo-SetupLiteral -Text $row.Step.Name Would = $would } } $tableArguments = $script:TableArguments Format-AnsiTable -Data @($rows) @tableArguments | Out-AnsiHost $pending = @($Plan | Where-Object -FilterScript { -not $_.Done }) $restarts = @($pending | Where-Object -FilterScript { $_.Step.RequiresRestart }).Count $summary = 'Dry run: {0} of {1} steps would run, and {2} would restart the machine. Nothing ran.' -f $pending.Count, $Plan.Count, $restarts Write-SetupNote -Text $summary -Color 'BrightCyan' } # ── Results ────────────────────────────────────────────────────────────────── function Write-SetupSummary { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Steps, [Parameter(Mandatory)] [hashtable] $State ) if ($Steps.Count -eq 0) { return } $rows = foreach ($step in $Steps) { $result = $State.Results[$step.Id] $status = 'NotRun' $time = '' if ($result) { $status = $result.Status if ($result.Seconds -gt 0 -and $status -ne 'Skipped') { $time = Format-SetupDuration -Duration ([TimeSpan]::FromSeconds($result.Seconds)) } } [pscustomobject]@{ '#' = Format-SetupOrder -Step $step Step = ConvertTo-SetupLiteral -Text $step.Name Result = $script:ResultLabel[$status] Time = $time } } Write-SetupBlankLine $tableArguments = $script:TableArguments Format-AnsiTable -Data @($rows) @tableArguments | Out-AnsiHost } function Write-SetupFailure { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $ErrorRecord, [AllowEmptyString()] [AllowNull()] [string] $LogFile ) Write-SetupBlankLine Format-AnsiRule -Title '[BrightRed]Failed[/]' -Alignment Left -LineColor DarkGray | Out-AnsiHost Format-AnsiException -Exception $ErrorRecord | Out-AnsiHost if ($LogFile) { Format-AnsiText -Message ('[DarkGray]Log[/] {0}' -f (ConvertTo-SetupLiteral -Text $LogFile)) | Out-AnsiHost } } function Wait-SetupClose { # A window this run opened itself, elevated or resumed, stays open until a key is pressed, so its last screen # can be read. Unattended runs skip the pause so .done sentinels and CI harnesses can proceed without a human. [CmdletBinding()] param( [switch] $Unattended ) if ($Unattended -or -not $env:PWSHSETUP_RERUN -or -not (Test-SetupInteractive)) { return } Write-SetupBlankLine $null = Read-AnsiPause -Message 'Press any key to close' } #endregion Shell.psm1 #region New-SetupStep.psm1 <# .SYNOPSIS Public: New-SetupStep, the object every step file returns. #> function New-SetupStep { <# .SYNOPSIS Describes one setup step. A step file returns exactly one. .DESCRIPTION Test says whether the step is already done: $true skips it. Omit it and the step always runs. Action does the work. Throwing fails the step and stops the run. RequiresRestart restarts the machine once Action is done, and the run carries on with the next step after the next sign-in. The run order comes from the number the file name starts with. .PARAMETER Name What the screen and the log call the step. .PARAMETER Action The work. .PARAMETER Test $true when the work is already done. .PARAMETER RequiresRestart Restart once Action is done. .EXAMPLE New-SetupStep -Name 'Install Git' -Test { Test-SetupCommand -Name 'git' } -Action { Install-ChocoPackage -Name 'git' } #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name, [Parameter(Mandatory)] [ValidateNotNull()] [scriptblock] $Action, [ValidateNotNull()] [scriptblock] $Test, [switch] $RequiresRestart ) return [pscustomobject]@{ PSTypeName = 'PwshSetup.Step' Id = $null Order = [int]::MaxValue Name = $Name Test = $Test Action = $Action RequiresRestart = [bool]$RequiresRestart File = $null Template = $null Module = $null } } #endregion New-SetupStep.psm1 #region Write-SetupLog.psm1 <# .SYNOPSIS Public: Write-SetupLog, a line in the run log that also shows under the running step. #> function Write-SetupLog { <# .SYNOPSIS Writes a line to the run log, and shows it under the running step. .DESCRIPTION Inside a step the line goes to the log file and appears dimmed under the step on screen. Outside a run it only goes to the screen. .PARAMETER Message The line. .PARAMETER Level Info, Warning or Error. Warnings show yellow and errors red. .EXAMPLE Write-SetupLog -Message 'Hosts file updated.' .EXAMPLE Write-SetupLog -Message 'Nothing to copy.' -Level Warning #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyString()] [string] $Message, [ValidateSet('Info', 'Warning', 'Error')] [string] $Level = 'Info' ) process { $fileLevel = 'INFO' $color = $null if ($Level -eq 'Warning') { $fileLevel = 'WARN' $color = 'BrightYellow' } elseif ($Level -eq 'Error') { $fileLevel = 'ERROR' $color = 'BrightRed' } Write-SetupLogFile -Message $Message -Level $fileLevel $text = ConvertTo-SetupLiteral -Text $Message if ($color) { $text = '[{0}]{1}[/]' -f $color, $text } $task = (Get-SetupContext).Task if ($task) { $task.Write($text) return } Format-AnsiText -Message $text | Out-AnsiHost } } #endregion Write-SetupLog.psm1 #region Request-SetupRestart.psm1 <# .SYNOPSIS Public: Request-SetupRestart, a restart a step asks for while it runs. #> function Request-SetupRestart { <# .SYNOPSIS Asks for a restart once the running step ends. .DESCRIPTION Without -Retry the step counts as done, and the run carries on with the next step after the restart. With -Retry the step could not do its work yet and runs again after the restart. A step that asks for a retry twice in a row fails. Outside a run it does nothing. .PARAMETER Retry Run this step again after the restart. .EXAMPLE if ($process.ExitCode -eq 3010) { Request-SetupRestart } .EXAMPLE Request-SetupRestart -Retry #> [CmdletBinding()] param( [switch] $Retry ) $context = Get-SetupContext if ($Retry) { $context.RestartRequest = 'Retry' Write-SetupLogFile -Message 'Restart requested; this step runs again after it.' return } if ($context.RestartRequest -ne 'Retry') { $context.RestartRequest = 'After' } Write-SetupLogFile -Message 'Restart requested after this step.' } #endregion Request-SetupRestart.psm1 #region Test-SetupCommand.psm1 <# .SYNOPSIS Public: Test-SetupCommand and Test-SetupProgram, the two checks most step Tests need. #> $script:UninstallKeys = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' ) function Test-SetupCommand { <# .SYNOPSIS $true when a command, such as git or dotnet, resolves in this session. .PARAMETER Name The command. .EXAMPLE Test-SetupCommand -Name 'git' #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name ) return [bool](Get-Command -Name $Name -ErrorAction Ignore) } function Test-SetupProgram { <# .SYNOPSIS $true when Windows lists a program under Installed apps, by display name. .DESCRIPTION Reads the machine and current user uninstall entries, the list Settings shows. Wildcards are allowed, so 'Mozilla Firefox*' matches every edition and language. .PARAMETER Name The display name, wildcards allowed. .EXAMPLE Test-SetupProgram -Name 'Google Chrome' .EXAMPLE Test-SetupProgram -Name 'SQL Server Management Studio*' #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name ) foreach ($key in $script:UninstallKeys) { $match = Get-ItemProperty -Path $key -ErrorAction Ignore | Where-Object -FilterScript { $_.PSObject.Properties['DisplayName'] -and $_.DisplayName -like $Name } | Select-Object -First 1 if ($match) { return $true } } return $false } #endregion Test-SetupCommand.psm1 #region Update-SetupEnvironment.psm1 <# .SYNOPSIS Public: Update-SetupEnvironment, so a step sees what the step before it installed. #> function Update-SetupEnvironment { <# .SYNOPSIS Reloads the machine and user environment variables into this session. .DESCRIPTION Installers write PATH and other variables to the registry, which a running session never rereads. This copies them in, machine first and user second, and joins both PATHs, so a command installed by one step resolves in the next. Install-ChocoPackage calls it after every install. .EXAMPLE Update-SetupEnvironment #> [CmdletBinding()] param() $machine = [Environment]::GetEnvironmentVariables('Machine') $user = [Environment]::GetEnvironmentVariables('User') foreach ($scope in $machine, $user) { foreach ($name in $scope.Keys) { if ($name -in 'Path', 'PSModulePath', 'USERNAME') { continue } [Environment]::SetEnvironmentVariable($name, [string]$scope[$name], 'Process') } } $paths = @([string]$machine['Path'], [string]$user['Path']) -split ';' | Where-Object -FilterScript { $_ } $env:Path = ($paths | Select-Object -Unique) -join ';' } #endregion Update-SetupEnvironment.psm1 #region Get-SetupTemplate.psm1 <# .SYNOPSIS Public: Get-SetupTemplate, the templates a step file can name with Use-SetupTemplate. #> function Get-SetupTemplate { <# .SYNOPSIS Lists the templates a step file can name with Use-SetupTemplate. .DESCRIPTION The templates published with the module, and with -StepPath your own from the templates folder beside that steps folder. One object per template: Id, the step's Name, its Parameters, its Source (Own or Module) and Path. Reading a template never runs it. .PARAMETER Id Template ids to return, wildcards allowed. All of them when omitted. .PARAMETER StepPath A steps folder, to list your own templates beside it too. .EXAMPLE Get-SetupTemplate .EXAMPLE Get-SetupTemplate -Id '*Sql*' -StepPath 'C:\Setup\steps' #> [CmdletBinding()] [OutputType([pscustomobject])] param( [ValidateNotNullOrEmpty()] [string[]] $Id = @('*'), [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] [string] $StepPath ) $sources = [System.Collections.Generic.List[object]]::new() if ($StepPath) { $ownRoot = Get-SetupOwnTemplateRoot -StepPath $StepPath if ($ownRoot) { $sources.Add([pscustomobject]@{ Source = 'Own'; Root = $ownRoot }) } } $sources.Add([pscustomobject]@{ Source = 'Module'; Root = (Get-SetupTemplateRoot) }) foreach ($source in $sources) { $files = Get-ChildItem -LiteralPath $source.Root -Filter '*.ps1' -File | Sort-Object -Property Name foreach ($file in $files) { $templateId = $file.BaseName if (@($Id | Where-Object -FilterScript { $templateId -like $_ }).Count -eq 0) { continue } $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$null) [pscustomobject]@{ PSTypeName = 'PwshSetup.Template' Id = $templateId Name = Get-SetupTemplateName -Ast $ast -Default $templateId Parameters = @(Get-SetupTemplateParameter -Ast $ast) Source = $source.Source Path = $file.FullName } } } } function Get-SetupTemplateParameter { [CmdletBinding()] [OutputType([string[]])] param( [Parameter(Mandatory)] [System.Management.Automation.Language.ScriptBlockAst] $Ast ) if (-not $Ast.ParamBlock) { return @() } return @($Ast.ParamBlock.Parameters | ForEach-Object -Process { $_.Name.VariablePath.UserPath }) } function Get-SetupTemplateName { # The step's name as a literal, read from the syntax tree: New-SetupStep -Name, or the Name key of the hashtable # splatted into it. The default when the name is built at run time. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Management.Automation.Language.ScriptBlockAst] $Ast, [Parameter(Mandatory)] [string] $Default ) $isCommand = { param($Node) $Node -is [System.Management.Automation.Language.CommandAst] -and $Node.GetCommandName() -eq 'New-SetupStep' } $command = $Ast.Find($isCommand, $true) if ($command) { $elements = $command.CommandElements for ($index = 0; $index -lt $elements.Count - 1; $index++) { $element = $elements[$index] $isName = $element -is [System.Management.Automation.Language.CommandParameterAst] -and $element.ParameterName -eq 'Name' if ($isName -and $elements[$index + 1] -is [System.Management.Automation.Language.StringConstantExpressionAst]) { return $elements[$index + 1].Value } } } $hashtables = $Ast.FindAll({ param($Node) $Node -is [System.Management.Automation.Language.HashtableAst] }, $true) foreach ($hashtable in $hashtables) { foreach ($pair in $hashtable.KeyValuePairs) { if ($pair.Item1 -isnot [System.Management.Automation.Language.StringConstantExpressionAst] -or $pair.Item1.Value -ne 'Name') { continue } $value = $pair.Item2 if ($value -is [System.Management.Automation.Language.PipelineAst] -and $value.PipelineElements.Count -eq 1) { $expression = $value.PipelineElements[0].Expression if ($expression -is [System.Management.Automation.Language.StringConstantExpressionAst]) { return $expression.Value } } } } return $Default } #endregion Get-SetupTemplate.psm1 #region Use-SetupTemplate.psm1 <# .SYNOPSIS Public: Use-SetupTemplate, a step file that is one line naming a template. #> function Use-SetupTemplate { <# .SYNOPSIS Returns the step a template describes, for a step file to return as its own. .DESCRIPTION Looks for the template in the templates folder beside the steps folder first, where your own live, then among the templates published with the module. Your own template of the same name wins. The template runs in a module of its own, with -Parameter as its parameters. .PARAMETER Name The template, by file name without .ps1, such as Install-Git. .PARAMETER Parameter Values for the template's parameters, such as @{ Entries = '127.0.0.1 shop.local' }. .EXAMPLE Use-SetupTemplate -Name 'Install-Git' .EXAMPLE Use-SetupTemplate -Name 'Install-DotnetSdk' -Parameter @{ Version = 8 } #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9-]+$')] [string] $Name, [hashtable] $Parameter = @{} ) $path = Find-SetupTemplate -Name $Name try { $step = Import-SetupScript -Path $path -Parameter $Parameter } catch { throw ('Template {0}: {1}' -f $Name, $_.Exception.Message) } $step.Template = $Name return $step } function Find-SetupTemplate { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $Name ) $fileName = '{0}.ps1' -f $Name $roots = @(Get-SetupOwnTemplateRoot) + @(Get-SetupTemplateRoot) | Where-Object -FilterScript { $_ } foreach ($root in $roots) { $path = Join-Path -Path $root -ChildPath $fileName if (Test-Path -LiteralPath $path -PathType Leaf) { return $path } } throw ('No template named {0}. Get-SetupTemplate lists them; your own go in a templates folder beside the steps folder.' -f $Name) } #endregion Use-SetupTemplate.psm1 #region Install-ChocoPackage.psm1 <# .SYNOPSIS Public: Install-ChocoPackage and Invoke-ChocoCommand, Chocolatey for steps. .DESCRIPTION Success is decided by Chocolatey's exit code, never by searching its output. A package that asks for a restart gets one after its step, and a package Chocolatey refuses because a restart is pending runs again after that restart. #> # Chocolatey's own contract: 1641 and 3010 installed and want a restart, 350 and 1604 did nothing because one is pending. $script:ChocoRestartExitCodes = @(1641, 3010) $script:ChocoPendingExitCodes = @(350, 1604) $script:ChocoTailLines = 15 function Invoke-ChocoCommand { <# .SYNOPSIS Runs choco with the arguments given, and returns its exit code and output. .DESCRIPTION The output goes to the run log in full; the screen shows the command line only. Throws when Chocolatey is not installed. .PARAMETER ArgumentList The arguments, one per item. .EXAMPLE Invoke-ChocoCommand -ArgumentList 'feature', 'enable', '--name=exitOnRebootDetected' #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $ArgumentList ) $choco = Get-Command -Name 'choco' -CommandType Application -ErrorAction Ignore | Select-Object -First 1 if (-not $choco) { throw 'Chocolatey is not installed. Put the Chocolatey step before this one.' } Write-SetupLog -Message ('choco {0}' -f ($ArgumentList -join ' ')) $output = & $choco.Source @ArgumentList 2>&1 | Out-String $exitCode = $LASTEXITCODE Write-SetupLogFile -Message $output -Level 'OUTPUT' return [pscustomobject]@{ PSTypeName = 'PwshSetup.ChocoResult' ExitCode = $exitCode Output = $output.TrimEnd() } } function Install-ChocoPackage { <# .SYNOPSIS Installs one Chocolatey package, and throws when it fails. .DESCRIPTION Exit 0 is done. 1641 and 3010 are done and ask for a restart after the step. 350 and 1604 mean Chocolatey saw a pending restart and installed nothing: the step runs again after a restart. Anything else throws with the last lines of Chocolatey's output. The session environment is reloaded after an install, so the next step finds the new commands. .PARAMETER Name The package id. .PARAMETER Version A version to pin. The latest when omitted. .PARAMETER AdditionalArgument More choco arguments, one per item, such as '--params', '/NoDesktopShortcut'. .PARAMETER PassThru Return the result object. .EXAMPLE Install-ChocoPackage -Name 'git' .EXAMPLE Install-ChocoPackage -Name 'googlechrome' -AdditionalArgument '--ignore-checksums' #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name, [ValidateNotNullOrEmpty()] [string] $Version, [string[]] $AdditionalArgument = @(), [switch] $PassThru ) $arguments = @('install', $Name, '--yes', '--no-progress') if ($Version) { $arguments += @('--version', $Version) } $arguments += $AdditionalArgument $result = Invoke-ChocoCommand -ArgumentList $arguments if ($result.ExitCode -in $script:ChocoPendingExitCodes) { Write-SetupLog -Message ('Chocolatey sees a pending restart; {0} installs after it.' -f $Name) -Level Warning Request-SetupRestart -Retry } elseif ($result.ExitCode -in $script:ChocoRestartExitCodes) { Write-SetupLog -Message ('{0} installed, and asks for a restart.' -f $Name) Request-SetupRestart Update-SetupEnvironment } elseif ($result.ExitCode -eq 0) { Write-SetupLog -Message ('{0} installed.' -f $Name) Update-SetupEnvironment } else { $tail = ($result.Output -split '\r?\n' | Select-Object -Last $script:ChocoTailLines) -join [Environment]::NewLine throw ('choco install {0} failed with exit code {1}.{2}{3}' -f $Name, $result.ExitCode, [Environment]::NewLine, $tail) } if ($PassThru) { return $result } } #endregion Install-ChocoPackage.psm1 #region Invoke-SetupSql.psm1 <# .SYNOPSIS Public: Invoke-SetupSql, Invoke-SetupSqlScalar and Test-SetupSqlDatabase, the SQL Server calls a step needs. .DESCRIPTION Values travel as parameters, never concatenated into the statement. #> function New-SetupSqlCommand { # Callers dispose the connection. [CmdletBinding()] [OutputType([System.Data.SqlClient.SqlCommand])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sql, [hashtable] $Parameter = @{} ) $connection = [System.Data.SqlClient.SqlConnection]::new($ConnectionString) $command = $connection.CreateCommand() $command.CommandText = $Sql foreach ($entry in $Parameter.GetEnumerator()) { $null = $command.Parameters.AddWithValue($entry.Key, $entry.Value) } return $command } function Invoke-SetupSql { <# .SYNOPSIS Runs a statement and returns the rows it affected. .PARAMETER ConnectionString Where to run it. .PARAMETER Sql The statement, with @name placeholders for values. .PARAMETER Parameter The values, keyed by placeholder. .EXAMPLE Invoke-SetupSql -ConnectionString $connectionString -Sql 'DELETE FROM Cache WHERE Region = @region' -Parameter @{ '@region' = 'eu' } #> [CmdletBinding()] [OutputType([int])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sql, [hashtable] $Parameter = @{} ) $command = New-SetupSqlCommand -ConnectionString $ConnectionString -Sql $Sql -Parameter $Parameter try { $command.Connection.Open() return $command.ExecuteNonQuery() } finally { $command.Connection.Dispose() } } function Invoke-SetupSqlScalar { <# .SYNOPSIS Runs a query and returns the first column of its first row. .PARAMETER ConnectionString Where to run it. .PARAMETER Sql The query, with @name placeholders for values. .PARAMETER Parameter The values, keyed by placeholder. .EXAMPLE Invoke-SetupSqlScalar -ConnectionString $connectionString -Sql 'SELECT COUNT(*) FROM sys.tables' #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Sql, [hashtable] $Parameter = @{} ) $command = New-SetupSqlCommand -ConnectionString $ConnectionString -Sql $Sql -Parameter $Parameter try { $command.Connection.Open() return $command.ExecuteScalar() } finally { $command.Connection.Dispose() } } function Test-SetupSqlDatabase { <# .SYNOPSIS $true when the database a connection string names exists on its server. .DESCRIPTION Connects to master on the same server, so it works before the database is created. .PARAMETER ConnectionString A connection string with Database or Initial Catalog. .EXAMPLE Test-SetupSqlDatabase -ConnectionString 'Server=.;Database=Shop;Integrated Security=True;TrustServerCertificate=True' #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConnectionString ) $builder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new($ConnectionString) $database = $builder.InitialCatalog if (-not $database) { throw 'The connection string names no Database or Initial Catalog.' } # Indexer rather than the InitialCatalog property setter: pwsh 7's System.Data.SqlClient throws # "Keyword not supported: 'InitialCatalog'" from the property setter path. The 'Initial Catalog' # indexer key uses the standard connection-string keyword and works. $builder['Initial Catalog'] = 'master' $scalarArguments = @{ ConnectionString = $builder.ConnectionString Sql = 'SELECT COUNT(*) FROM sys.databases WHERE name = @name' Parameter = @{ '@name' = $database } } return ([int](Invoke-SetupSqlScalar @scalarArguments) -gt 0) } #endregion Invoke-SetupSql.psm1 #region Invoke-PwshSetup.psm1 <# .SYNOPSIS Public: Invoke-PwshSetup, runs a folder of setup steps and carries on across restarts. #> $script:AppTitle = 'PwshSetup' function Invoke-PwshSetup { <# .SYNOPSIS Runs the steps in a folder, in order, and carries on across restarts on its own. .DESCRIPTION Lists the folder's steps. F6 runs them and F7 dry-runs them, each after a y/n confirm; Esc quits. A run checks each step right before it and skips what is done. A step that needs a restart gets one: the run registers itself to start again at the next sign-in, counts down ten seconds (Esc cancels), restarts, and carries on with the next step without asking. A step that fails stops the run; the next run asks to carry on from it. Needs an elevated session, except with -WhatIf. Call Assert-SetupAdmin first. Logs go to %ProgramData%\PwshSetup\Logs, one file per run. .PARAMETER Path The steps folder. Every .ps1 in it is a step, run in the order of the number its name starts with. .PARAMETER LogPath Folder for the log files. .PARAMETER Unattended Run without the list and the confirm. .PARAMETER SkipRestart Note a needed restart and carry on without one. .PARAMETER ScriptPath Script the resume task starts after a restart. Defaults to the calling script. .PARAMETER Arguments Parameters the resume task passes to that script. Defaults to the calling script's bound parameters. .PARAMETER PassThru Return the outcome: Status (Finished, Failed, Restarting, Cancelled, DryRun) and LogFile. .EXAMPLE Invoke-PwshSetup -Path (Join-Path -Path $PSScriptRoot -ChildPath 'steps') .EXAMPLE Invoke-PwshSetup -Path 'C:\Setup\steps' -WhatIf #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [ValidateNotNullOrEmpty()] [string] $LogPath = (Get-SetupDataPath -Name 'Logs'), [switch] $Unattended, [switch] $SkipRestart, [AllowEmptyString()] [string] $ScriptPath = $MyInvocation.PSCommandPath, [hashtable] $Arguments, [switch] $PassThru ) if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw ('Step folder not found: {0}' -f $Path) } $dryRun = [bool]$WhatIfPreference if (-not $dryRun -and -not (Test-SetupAdmin)) { throw 'Invoke-PwshSetup needs an elevated session. Call Assert-SetupAdmin before it.' } if (-not $dryRun -and -not $Unattended -and -not (Test-SetupInteractive)) { throw 'No console to show the list in. Pass -Unattended to run without it.' } if ($null -eq $Arguments) { $Arguments = @{} $caller = Get-PSCallStack | Select-Object -Skip 1 -First 1 if ($caller) { foreach ($key in $caller.InvocationInfo.BoundParameters.Keys) { $Arguments[$key] = $caller.InvocationInfo.BoundParameters[$key] } } } [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $runArguments = @{ StepPath = (Resolve-Path -LiteralPath $Path).ProviderPath LogPath = $LogPath Unattended = [bool]$Unattended SkipRestart = [bool]$SkipRestart DryRun = $dryRun ScriptPath = $ScriptPath Arguments = $Arguments } try { $outcome = Invoke-SetupRun @runArguments } finally { Reset-SetupContext } if ($PassThru) { return $outcome } } # ── Run ────────────────────────────────────────────────────────────────────── function Invoke-SetupRun { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $StepPath, [Parameter(Mandatory)] [string] $LogPath, [Parameter(Mandatory)] [bool] $Unattended, [Parameter(Mandatory)] [bool] $SkipRestart, [Parameter(Mandatory)] [bool] $DryRun, [AllowEmptyString()] [string] $ScriptPath, [hashtable] $Arguments ) $rerun = @(([string]$env:PWSHSETUP_RERUN) -split ',' | Where-Object -FilterScript { $_ }) if (-not $DryRun -and $rerun -contains 'resume') { Unregister-SetupResume } $header = @( , @('Steps', (ConvertTo-SetupLiteral -Text $StepPath)) , @('Machine', ('{0} [DarkGray]{1}[/]' -f $env:COMPUTERNAME, (ConvertTo-SetupLiteral -Text $env:USERNAME))) ) Clear-SetupScreen try { $steps = @(Import-SetupStep -Path $StepPath) } catch { Write-SetupHeader -Item $header Write-SetupFailure -ErrorRecord $_ Wait-SetupClose -Unattended:$Unattended return (New-SetupOutcome -Status 'Failed') } if ($steps.Count -eq 0) { Write-SetupHeader -Item $header Write-SetupNote -Text 'No step files in the folder.' -Color 'BrightYellow' return (New-SetupOutcome -Status 'Finished') } if ($DryRun) { Write-SetupHeader -Item $header Write-SetupPlan -Plan (Get-SetupPlan -Steps $steps) return (New-SetupOutcome -Status 'DryRun') } # ── Resume, or the list ────────────────────────────────────────────────── $state = Read-SetupState -StepPath $StepPath $resumed = $false if ($state) { if ((Test-SetupRestartedSince -State $state) -or $Unattended -or $rerun -contains 'resume') { $resumed = $true } else { Write-SetupHeader -Item $header $startedAt = (ConvertTo-SetupUtc -Value $state.StartedAt).ToLocalTime() $startedText = $startedAt.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture) $prompt = 'Carry on with the unfinished run from {0}?' -f $startedText $answer = Read-AnsiConfirm -Prompt $prompt -Default $true if ($null -eq $answer) { return (New-SetupOutcome -Status 'Cancelled') } $resumed = [bool]$answer if (-not $resumed) { Remove-SetupState -StepPath $StepPath $state = $null } } } if (-not $resumed) { if (-not $Unattended -and -not (Wait-SetupGo -Steps $steps -Header $header)) { Write-SetupNote -Text 'Nothing ran.' -Color 'DarkGray' return (New-SetupOutcome -Status 'Cancelled') } if (-not (Test-Path -LiteralPath $LogPath)) { $null = New-Item -ItemType Directory -Path $LogPath -Force } $stateArguments = @{ StepPath = $StepPath ScriptPath = $ScriptPath LogFile = Join-Path -Path $LogPath -ChildPath ('PwshSetup-{0}.log' -f (Get-Date -Format 'yyyy-MM-dd_HH-mm-ss')) Selected = @($steps | ForEach-Object -Process { $_.Id }) } $state = New-SetupState @stateArguments } Clear-SetupScreen $runKind = 'started' $runText = 'started' if ($resumed) { $runKind = 'resumed' $runText = 'resumed [DarkGray]{0} restart(s) so far[/]' -f $state.Restarts } $header += , @('Run', $runText) $header += , @('Log', (ConvertTo-SetupLiteral -Text $state.LogFile)) Write-SetupHeader -Item $header $state.RestartingAt = $null Save-SetupState -State $state $argumentFile = Save-SetupArgument -StepPath $StepPath -Arguments $Arguments $context = Get-SetupContext $context.LogFile = $state.LogFile Write-SetupLogFile -Message ('Run {0} on {1} as {2}, steps in {3}.' -f $runKind, $env:COMPUTERNAME, $env:USERNAME, $StepPath) # ── Steps ──────────────────────────────────────────────────────────────── $byId = @{} foreach ($step in $steps) { $byId[$step.Id] = $step } foreach ($id in @($state.Selected | Where-Object -FilterScript { -not $byId.ContainsKey($_) })) { Write-SetupLogFile -Message ('Step {0} has no file any more; left out.' -f $id) -Level 'WARN' } $runSteps = @($state.Selected | Where-Object -FilterScript { $byId.ContainsKey($_) } | ForEach-Object -Process { $byId[$_] }) $remaining = @($runSteps | Where-Object -FilterScript { $result = $state.Results[$_.Id] -not $result -or $result.Status -notin 'Done', 'Skipped' }) $run = [pscustomobject]@{ Steps = $remaining State = $state SkipRestart = $SkipRestart RestartStep = $null } $stepScript = { param($Task) Invoke-SetupStepTask -Task $Task -Run $run } $tasks = foreach ($step in $remaining) { @{ Name = $step.Name; Script = $stepScript } } $failure = $null $null = Start-AnsiTitleAnimation -Text $script:AppTitle try { if (@($tasks).Count -gt 0) { Invoke-AnsiTask -Task @($tasks) -Show Both -ProgressShow Percent } } catch { $failure = $_ } finally { Stop-AnsiTitleAnimation -Title $script:AppTitle } # ── Outcome ────────────────────────────────────────────────────────────── if ($failure) { Write-SetupLogFile -Message 'Run failed.' -Level 'ERROR' Write-SetupSummary -Steps $runSteps -State $state Write-SetupFailure -ErrorRecord $failure -LogFile $state.LogFile Write-SetupNote -Text 'Fix it and run setup again: it carries on from the failed step.' -Color 'DarkGray' Wait-SetupClose -Unattended:$Unattended return (New-SetupOutcome -Status 'Failed' -LogFile $state.LogFile) } if ($run.RestartStep) { Write-SetupBlankLine if (-not $state.ScriptPath) { Save-SetupState -State $state Write-SetupNote -Text 'Restart the machine, then run setup again to carry on.' -Color 'BrightYellow' return (New-SetupOutcome -Status 'Cancelled' -LogFile $state.LogFile) } $state.Restarts++ $state.RestartingAt = (Get-Date).ToUniversalTime().ToString('o') Save-SetupState -State $state Register-SetupResume -ScriptPath $state.ScriptPath -ArgumentFile $argumentFile if (Restart-SetupComputer -StepName $run.RestartStep.Name) { return (New-SetupOutcome -Status 'Restarting' -LogFile $state.LogFile) } Unregister-SetupResume $state.Restarts-- $state.RestartingAt = $null Save-SetupState -State $state Write-SetupNote -Text 'Restart cancelled. Restart the machine, then run setup again to carry on.' -Color 'BrightYellow' Wait-SetupClose -Unattended:$Unattended return (New-SetupOutcome -Status 'Cancelled' -LogFile $state.LogFile) } $elapsed = Format-SetupDuration -Duration ((Get-Date).ToUniversalTime() - (ConvertTo-SetupUtc -Value $state.StartedAt)) Write-SetupLogFile -Message 'Run finished.' Write-SetupSummary -Steps $runSteps -State $state Remove-SetupState -StepPath $StepPath Write-SetupBlankLine Write-SetupNote -Text ('Setup finished in {0}, with {1} restart(s).' -f $elapsed, $state.Restarts) -Color 'BrightGreen' Format-AnsiText -Message ('[DarkGray]Log[/] {0}' -f (ConvertTo-SetupLiteral -Text $state.LogFile)) | Out-AnsiHost Wait-SetupClose -Unattended:$Unattended return (New-SetupOutcome -Status 'Finished' -LogFile $state.LogFile) } function Wait-SetupGo { # The list screen: F6 runs and F7 dry-runs, each after a y/n; Esc quits. $true once a run is confirmed. [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [object[]] $Steps, [Parameter(Mandatory)] [object[]] $Header ) while ($true) { Clear-SetupScreen Write-SetupHeader -Item $Header Write-SetupKeyLine -Key @('F6 Run', 'F7 Dry run', 'Esc Quit') Write-SetupList -Steps $Steps $entered = Read-SetupKey -Key @('F6', 'F7') if ($entered.Action -eq 'Back') { return $false } if ($entered.Action -ne 'Key') { continue } Write-SetupBlankLine if ($entered.Key -eq 'F6') { if (Read-SetupConfirm -Prompt ('Run {0} steps?' -f $Steps.Count)) { return $true } continue } if (Read-SetupConfirm -Prompt ('Dry run {0} steps?' -f $Steps.Count)) { Write-SetupBlankLine Write-SetupPlan -Plan (Get-SetupPlan -Steps $Steps) Write-SetupBlankLine $null = Read-AnsiPause -Message 'Press any key to go back to the list' } } } function New-SetupOutcome { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateSet('Finished', 'Failed', 'Restarting', 'Cancelled', 'DryRun')] [string] $Status, [AllowEmptyString()] [string] $LogFile = '' ) return [pscustomobject]@{ PSTypeName = 'PwshSetup.Outcome' Status = $Status LogFile = $LogFile } } # ── Step ───────────────────────────────────────────────────────────────────── function Invoke-SetupStepTask { # One step inside Invoke-AnsiTask: checks it, since an earlier step may have done its work, runs it, records the # result, and stops the run when it needs a restart. [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Task, [Parameter(Mandatory)] [pscustomobject] $Run ) $step = $Run.Steps[$Task.Index] $state = $Run.State $context = Get-SetupContext $context.Task = $Task $context.StepId = $step.Id $context.RestartRequest = $null $watch = [System.Diagnostics.Stopwatch]::StartNew() Write-SetupLogFile -Message ('Started: {0}' -f $step.Name) try { $check = Test-SetupStepDone -Step $step if ($check.Error) { Write-SetupLogFile -Message ('Check failed, so the step runs: {0}' -f $check.Error) -Level 'WARN' } if ($check.Done) { Set-SetupStepResult -State $state -Id $step.Id -Status 'Skipped' -Duration $watch.Elapsed Write-SetupLogFile -Message 'Already done, skipped.' $Task.Skip('already done') return } Invoke-SetupStepAction -Step $step $request = $context.RestartRequest if ($request -eq 'Retry') { $tries = 1 if ($state.Retries.ContainsKey($step.Id)) { $tries = [int]$state.Retries[$step.Id] + 1 } if ($tries -gt 1) { throw ('{0} still waits for a restart after one. Restart the machine and run setup again.' -f $step.Name) } $state.Retries[$step.Id] = $tries Set-SetupStepResult -State $state -Id $step.Id -Status 'Retry' -Duration $watch.Elapsed if ($Run.SkipRestart) { $Task.Skip('needs a restart first') return } $Run.RestartStep = $step $Task.Stop('restart, then again') return } Set-SetupStepResult -State $state -Id $step.Id -Status 'Done' -Duration $watch.Elapsed Write-SetupLogFile -Message ('Done in {0}.' -f (Format-SetupDuration -Duration $watch.Elapsed)) if ($step.RequiresRestart -or $request -eq 'After') { if ($Run.SkipRestart) { $Task.Write('restart skipped') Write-SetupLogFile -Message 'Restart skipped.' -Level 'WARN' return } $Run.RestartStep = $step $Task.Stop('restart') } } catch { Set-SetupStepResult -State $state -Id $step.Id -Status 'Failed' -Duration $watch.Elapsed -Reason $_.Exception.Message Write-SetupLogFile -Message ($_ | Out-String) -Level 'ERROR' throw } finally { Save-SetupState -State $state $context.Task = $null $context.StepId = $null } } function Invoke-SetupStepAction { # Output, errors and warnings of the Action go to the log, never between the progress lines. [CmdletBinding()] param( [Parameter(Mandatory)] [pscustomobject] $Step ) & $Step.Action 2>&1 3>&1 | ForEach-Object -Process { Write-SetupStepOutput -Record $_ } } function Write-SetupStepOutput { # Native commands write progress to stderr, so their error records are output, not errors. [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] [object] $Record ) if ($null -eq $Record) { return } if ($Record -is [System.Management.Automation.WarningRecord]) { Write-SetupLogFile -Message $Record.Message -Level 'WARN' return } if ($Record -is [System.Management.Automation.ErrorRecord]) { $level = 'ERROR' if ($Record.FullyQualifiedErrorId -like 'NativeCommandError*') { $level = 'OUTPUT' } Write-SetupLogFile -Message $Record.ToString() -Level $level return } Write-SetupLogFile -Message ($Record | Out-String -Width 200) -Level 'OUTPUT' } #endregion Invoke-PwshSetup.psm1 |