<# .SYNOPSIS Put setmeup on this machine and start it. .DESCRIPTION The first thing that happens on a freshly imaged PC, and the only step the host performs by hand: irm https://setmeup.def-net.org | iex It downloads the binary, installs it under the user's programs directory, puts that directory on the user PATH so that `setmeup` is one word in every terminal opened afterwards, and starts it. Fetched as a string and run by `iex` rather than saved and executed, which is what keeps it clear of the execution policy: there is no file here for Windows to have an opinion about. HTTPS is deliberate: this runs seconds before setmeup asks for administrator rights, and it is fetched across the open internet, so who served it is the only thing vouching for what is about to run as administrator. Re-running is how a machine gets a newer setmeup: an existing installation is replaced, including one that is running at the time. A download that fails or is cut off installs nothing and leaves nothing behind. Every character in this file is ASCII, and has to stay that way. It crosses the wire as text and is decoded by whatever the fetching side guesses: Windows PowerShell reads a response with no charset on it as ISO-8859-1, and reads this file off disk in the machine's ANSI codepage. An em dash survives neither, and in codepage 1252 its last byte becomes a curly quote, which PowerShell accepts as a string delimiter. The script then fails to parse several lines away from anything that looks wrong. verify-bootstrap.ps1 checks this, because it is not the kind of thing anyone spots by reading. .PARAMETER Source Where the binary comes from. Defaults to the host that served this script. Point it somewhere else to install a build that is not the released one, which is what a virtual machine verifying a change wants. .PARAMETER NoLaunch Install and stop, without starting setmeup. .EXAMPLE irm https://setmeup.def-net.org | iex .EXAMPLE & ([scriptblock]::Create((irm https://setmeup.def-net.org))) -NoLaunch Passing an argument needs the script as a script block: `iex` takes a string and has nowhere to put one. .NOTES Two things have to be served for the one-liner above to work: this script at the root of https://setmeup.def-net.org/, and the binary next to it at /setmeup.exe. How that host is set up, and how to put a release on it, is docs/delivery.md. Whether it is right today is verify-delivery.ps1. #> [CmdletBinding()] param( [string]$Source = 'https://setmeup.def-net.org/setmeup.exe', [switch]$NoLaunch ) # `iex` runs this straight into the host's own session, so all of it happens # inside a scope of its own: a preference this script wants for a few seconds is # not one the host's terminal should be left holding afterwards. The TLS floor # below is the exception and stays raised, because it is a setting on the # process rather than a variable in a scope, and lowering it again would be an # odd thing to do to somebody on purpose. & { $ErrorActionPreference = 'Stop' # Invoke-WebRequest redraws a progress bar per chunk on Windows PowerShell, # which costs more time than the download it is reporting on. $ProgressPreference = 'SilentlyContinue' # A fresh image negotiates whatever .NET's defaults allow, which on Windows # PowerShell can still reach down to TLS 1.0. The certificate on the other # end is the only thing vouching for a binary that is about to run as # administrator, so the floor is raised rather than inherited. Or-ed rather # than assigned, so a session that already allows more keeps it. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $directory = Join-Path $env:LOCALAPPDATA 'Programs\SetMeUp' $destination = Join-Path $directory 'setmeup.exe' # Downloaded to somewhere disposable rather than straight to its destination, # so that a download cut off halfway leaves a stray file in the temp # directory instead of half a setmeup where a working one used to be. $staged = Join-Path ([IO.Path]::GetTempPath()) "setmeup-$([Guid]::NewGuid()).exe" try { Invoke-WebRequest -Uri $Source -OutFile $staged -UseBasicParsing } catch { Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue throw "setmeup could not be downloaded from $Source. $($_.Exception.Message) Nothing was installed." } # A captive portal, or a proxy that has lost the host, answers 200 with a # page of HTML, and Invoke-WebRequest is perfectly happy with that. Two bytes # say whether what arrived is a Windows executable at all; without them the # host meets this as "setmeup is not recognized" from a fresh terminal and # goes looking at their PATH, which is the one thing that would be right. $head = New-Object byte[] 2 $reading = [IO.File]::OpenRead($staged) try { $read = $reading.Read($head, 0, $head.Length) } finally { $reading.Dispose() } if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue throw "$Source did not answer with a Windows executable, so nothing was installed. Check that the host is serving the binary and not an error page." } New-Item -ItemType Directory -Path $directory -Force | Out-Null # Re-running is how a machine gets a newer setmeup, so an installation that # is already there is replaced rather than complained about. A copy that is # running cannot be overwritten, because Windows holds its file open, but it # can be renamed out from under itself: which is why the old one moves aside # rather than being deleted, and why moving it back is what a failure does. # # Both moves are inside the same try, because the first one can fail too: a # superseded binary from an earlier run that is still open somewhere cannot # be deleted or written over, and a host who meets that deserves the same # sentence as any other failure rather than a raw Windows one. $superseded = "$destination.superseded" try { Remove-Item -LiteralPath $superseded -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $destination) { Move-Item -LiteralPath $destination -Destination $superseded -Force } Move-Item -LiteralPath $staged -Destination $destination } catch { # Only where the old one really did move out of the way and nothing took # its place, so that a failure cannot overwrite a newer setmeup with the # one it just replaced. if (-not (Test-Path -LiteralPath $destination) -and (Test-Path -LiteralPath $superseded)) { Move-Item -LiteralPath $superseded -Destination $destination } Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue throw "setmeup could not be installed to $destination. $($_.Exception.Message) The installation that was there is untouched." } # The copy that was running when this started holds its own file open until # it ends, so this is best effort and deliberately quiet: one superseded # binary sitting there until the next run is not worth a word to the host, # and the next run is what clears it. Remove-Item -LiteralPath $superseded -Force -ErrorAction SilentlyContinue # Read from where the user's own PATH is kept rather than from $env:PATH, # which is the machine's and the user's already joined together: writing that # back would copy every entry the machine has into this user's own, frozen as # it stands today and left behind by every later change to it. # # Read and written as it is stored, %VARIABLE% and all. The obvious # [Environment]::SetEnvironmentVariable hands back a string with every one of # them already expanded and stores it that way, so a PATH that said # %USERPROFILE% would come out naming today's profile for ever after. On a # fresh image that is one entry and no harm done; on the host's own machine # it is silently throwing away something they wrote on purpose. $environment = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) try { $stored = $environment.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $entries = @($stored -split ';' | Where-Object { $_ }) # Compared expanded, because an entry naming this directory through a # variable is the same directory and does not want a second row. $already = $entries | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\') -ieq $directory.TrimEnd('\') } if (-not $already) { # ExpandString even where nothing was stored at all: a user PATH is # the kind of value Windows expands, and whoever adds the next entry # through the control panel will be writing one that expects it. $kind = if ($entries) { $environment.GetValueKind('Path') } else { [Microsoft.Win32.RegistryValueKind]::ExpandString } $environment.SetValue('Path', (($entries + $directory) -join ';'), $kind) # The registry on its own changes nothing anybody can see: a terminal # opened from the Start menu inherits its environment from Explorer, # which keeps a copy of it and refreshes that copy only when it is # told the environment changed. This is the telling, and it is the # difference between `setmeup` being a word in a new terminal now and # being one after the host has signed out and back in. # # Announced, but not at the cost of the installation: the binary is # already in place and the PATH is already written by the time this # runs, and neither is worth losing to a machine that cannot compile # ten lines of C#. try { if (-not ('SetMeUp.Broadcast' -as [type])) { Add-Type -Namespace SetMeUp -Name Broadcast -MemberDefinition @' [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] public static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wparam, string lparam, uint flags, uint timeout, out UIntPtr answer); '@ } # To every window (0xFFFF), that a setting changed (0x001A), # giving up on any window that has stopped answering (0x0002) # after a second: a window that has hung must not hold up an # install. $answer = [UIntPtr]::Zero [void][SetMeUp.Broadcast]::SendMessageTimeout( [IntPtr]0xFFFF, 0x001A, [UIntPtr]::Zero, 'Environment', 0x0002, 1000, [ref]$answer) } catch { Write-Warning "setmeup is on your PATH, but Windows could not be told about it ($($_.Exception.Message)). A terminal opened after you next sign in will have it." } } } finally { $environment.Dispose() } # And this terminal, which took its copy of PATH when it started and would # otherwise be the one place where `setmeup` is not yet a word. if (-not (@($env:PATH -split ';') | Where-Object { $_.TrimEnd('\') -ieq $directory.TrimEnd('\') })) { $env:PATH = "$env:PATH;$directory" } Write-Host "setmeup is installed at $destination and is on your PATH. From here on it is one word in any new terminal." if ($NoLaunch) { return } Write-Host 'Starting setmeup.' # setmeup exits non-zero when a Package failed, which is a report rather than # a fault of this script. PowerShell 7.4 turns a non-zero native exit code # into a terminating error unless told not to, and a wall of red over an # honest Report is not what the host should be reading. $PSNativeCommandUseErrorActionPreference = $false & $destination }