# Install Maxon on Windows, from PowerShell: # # irm https://maxon.dev/install.ps1 | iex # # From cmd.exe or another shell, which runs it in a PowerShell of its own: # # powershell -c "irm https://maxon.dev/install.ps1 | iex" # # A specific release: # # & ([scriptblock]::Create((irm https://maxon.dev/install.ps1))) -Version 0.1.1 # # Downloads the x64 Windows release from GitHub, checks it against the release's SHA256SUMS, and # installs it into %USERPROFILE%\.maxon: the compiler in .maxon\bin, the standard library in # .maxon\stdlib and the language runtime in .maxon\runtime. Adds .maxon\bin to the user PATH and to # the PATH of the session it runs in; no administrator rights are needed. Running it again installs the # latest release, or says the install is current. # # A PowerShell started for it cannot change the PATH of the shell that started it, so in that case this # prints the line that does, in that shell's own syntax. # # Parameters: -Version X.Y.Z, -Force (reinstall a current install), -NoPathUpdate. # Environment: MAXON_INSTALL (default %USERPROFILE%\.maxon), MAXON_DOWNLOAD_BASE (a mirror laid out # as /v/; needs -Version). # Uninstall: delete %USERPROFILE%\.maxon and remove its bin directory from your user PATH. # # `maxon upgrade` RUNS THIS SCRIPT, SO ITS INTERFACE IS A CONTRACT WITH EVERY SHIPPED COMPILER: it # honours MAXON_INSTALL and -NoPathUpdate, and sets $LASTEXITCODE to 0 when the install is current and # 1 when it is not. Renaming or dropping any of those breaks `maxon upgrade` in every release that # has it. # # ASCII ONLY: Windows PowerShell 5.1's `irm` decodes a response without a charset as ISO-8859-1. # NEVER `exit`: under `irm | iex` this runs in whatever session `iex` is in, and `exit` would close it. # So `powershell -c "irm ... | iex"` exits 0 whatever happened; a caller that needs the status runs # & ([scriptblock]::Create((irm https://maxon.dev/install.ps1))); exit $LASTEXITCODE param( [string]$Version = '', [switch]$Force, [switch]$NoPathUpdate ) function Install-Maxon { param([string]$RequestedVersion, [bool]$Reinstall, [bool]$UpdatePath) $ErrorActionPreference = 'Stop' # A caller's PSModulePath may name another PowerShell edition's modules ahead of this host's: # `maxon upgrade` starts Windows PowerShell with the environment of whatever shell started IT, and # from pwsh 7 that path names pwsh 7's copies first. This host refuses to load them and then reports # Get-FileHash and Expand-Archive as not found, so its own module directory goes first. Nothing here # may use a cmdlet: the ones that would be are the ones at risk. $hostModules = "$PSHOME\Modules" if (-not "$env:PSModulePath;".StartsWith("$hostModules;", [System.StringComparison]::OrdinalIgnoreCase)) { $env:PSModulePath = "$hostModules;$env:PSModulePath".TrimEnd(';') } # The progress bar slows Invoke-WebRequest by an order of magnitude on Windows PowerShell 5.1. $ProgressPreference = 'SilentlyContinue' $repo = 'maxon-lang/maxon' $target = 'x64-windows' function Say([string]$message) { Write-Host "maxon-install: $message" } # A version is interpolated into URLs and file names, so anything but a version's own characters is refused. function Test-ReleaseVersion([string]$candidate) { if ($candidate -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$') { throw "'$candidate' is not a release version (expected X.Y.Z)" } } function Get-InstallRoot { $dir = $env:MAXON_INSTALL if ([string]::IsNullOrEmpty($dir)) { $dir = Join-Path $HOME '.maxon' } # A relative path would resolve against the process directory, which PowerShell does not keep in step with $PWD. if (-not [IO.Path]::IsPathRooted($dir)) { throw "MAXON_INSTALL must be an absolute path, not '$dir'" } $dir = [IO.Path]::GetFullPath($dir).TrimEnd('\') if ($dir.Length -le 2) { throw "MAXON_INSTALL cannot be the root of a drive" } return $dir } function Get-Architecture { # The registry answers for the machine; $env:PROCESSOR_ARCHITECTURE answers for this process, # which reports AMD64 under emulation on an ARM64 machine. $key = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' return (Get-ItemProperty -Path $key -Name PROCESSOR_ARCHITECTURE).PROCESSOR_ARCHITECTURE } # The newest release, from where GitHub redirects /releases/latest: no API call, so no rate limit. function Get-LatestVersion { $request = [Net.HttpWebRequest]::Create("https://github.com/$repo/releases/latest") $request.Method = 'HEAD' $request.AllowAutoRedirect = $false $request.UserAgent = 'maxon-install' $response = $request.GetResponse() try { $location = $response.Headers['Location'] } finally { $response.Close() } if ($location -notmatch '/tag/v([^/]+)$') { throw "could not read a version from GitHub's latest-release redirect ('$location')" } Test-ReleaseVersion $Matches[1] return $Matches[1] } # Runs a program to completion and returns its exit code and standard output. function Invoke-Captured([string]$program, [string]$arguments) { $info = New-Object Diagnostics.ProcessStartInfo $info.FileName = $program $info.Arguments = $arguments $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardOutput = $true $info.RedirectStandardError = $true $process = [Diagnostics.Process]::Start($info) # Both pipes drain at once, so a child filling one while this waits on the other cannot deadlock. $errors = $process.StandardError.ReadToEndAsync() $output = $process.StandardOutput.ReadToEnd() $process.WaitForExit() $null = $errors.Result return @{ ExitCode = $process.ExitCode; Output = $output } } # The version an installed compiler reports, or $null when there is none or it answers in a form # this script does not know. function Get-InstalledVersion([string]$exe) { if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { return $null } try { $result = Invoke-Captured $exe 'version' } catch { return $null } if ($result.Output -match '^maxon (\S+) ') { return $Matches[1] } return $null } function Get-Download([string]$url, [string]$path) { try { Invoke-WebRequest -Uri $url -OutFile $path -UseBasicParsing } catch { throw "could not download $url ($($_.Exception.Message))" } } function Test-Checksum([string]$sums, [string]$file) { $name = Split-Path -Leaf $file $expected = $null foreach ($line in Get-Content -LiteralPath $sums) { # `*` before a name is sha256sum's binary-mode marker. if ($line -match '^([0-9a-fA-F]{64}) [ *]?(.+)$' -and $Matches[2] -eq $name) { $expected = $Matches[1].ToLowerInvariant() } } if ($null -eq $expected) { throw "SHA256SUMS lists no $name" } $actual = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { throw "$name does not match its published checksum; nothing was installed" } } # Antivirus scanners hold a freshly written file open for a moment, so a rename is retried briefly. function Move-Entry([string]$from, [string]$to) { for ($attempt = 1; ; $attempt++) { try { if (Test-Path -LiteralPath $from -PathType Container) { [IO.Directory]::Move($from, $to) } else { [IO.File]::Move($from, $to) } return } catch { if ($attempt -ge 5) { # The .NET exception inside PowerShell's "Exception calling Move" wrapper is the one that names the cause. if ($null -ne $_.Exception.InnerException) { throw $_.Exception.InnerException } throw } Start-Sleep -Milliseconds 200 } } } function Clear-Leftover([string]$path) { try { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop } catch { # A leftover still in use (a compiler that is running) is removed by the next run instead. Write-Verbose "left $path for the next run to remove" } } # A running maxon.exe cannot be replaced or deleted, but it can be renamed, and bin\ itself cannot # be renamed while it runs. So stdlib\, runtime\ and examples\ are swapped whole, the old exe is # renamed aside, and the new one moved into its place. function Install-Release([string]$root, [string]$unpacked, [string]$id) { $bin = Join-Path $root 'bin' $exe = Join-Path $bin 'maxon.exe' $retired = Join-Path $root ".retired-$id" $null = New-Item -ItemType Directory -Path $retired $placed = New-Object Collections.Generic.List[string] $aside = $null try { # THE ARCHIVE DECIDES WHICH TIERS EXIST. `runtime\` is absent from releases that predate it, # and this script installs any version, so an entry the archive does not carry is only retired. foreach ($entry in @('stdlib', 'runtime', 'examples')) { $current = Join-Path $root $entry if (Test-Path -LiteralPath $current) { Move-Entry $current (Join-Path $retired $entry) } $incoming = Join-Path $unpacked $entry if (-not (Test-Path -LiteralPath $incoming)) { continue } Move-Entry $incoming $current $placed.Add($entry) } if (Test-Path -LiteralPath $exe) { # Not maxon.old.exe: bin\ is on PATH, and PATHEXT would make that a runnable `maxon.old`. $aside = "$exe.old" for ($n = 1; Test-Path -LiteralPath $aside; $n++) { $aside = "$exe.old$n" } Move-Entry $exe $aside } Move-Entry (Join-Path $unpacked 'maxon.exe') $exe } catch { $reason = $_.Exception.Message if ($null -ne $aside -and -not (Test-Path -LiteralPath $exe)) { Move-Entry $aside $exe } for ($i = $placed.Count - 1; $i -ge 0; $i--) { Remove-Item -LiteralPath (Join-Path $root $placed[$i]) -Recurse -Force } foreach ($entry in @('stdlib', 'runtime', 'examples')) { $saved = Join-Path $retired $entry if (Test-Path -LiteralPath $saved) { Move-Entry $saved (Join-Path $root $entry) } } Clear-Leftover $retired throw "could not move the new release into $root, so the previous install is unchanged. Close running Maxon processes, the VS Code language server included, and run this again. ($reason)" } Clear-Leftover $retired if ($null -ne $aside) { Clear-Leftover $aside } } function Add-ToUserPath([string]$dir) { $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) try { $names = $key.GetValueNames() $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString $current = '' if ($names -contains 'Path') { $kind = $key.GetValueKind('Path') # Read unexpanded, so %VARIABLE% entries survive the rewrite. $current = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } $wanted = $dir.TrimEnd('\') foreach ($entry in ($current -split ';')) { $expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim()).TrimEnd('\') if ($expanded -ieq $wanted) { return $false } } $updated = if ($current.Trim(';').Length -eq 0) { $dir } else { $current.TrimEnd(';') + ';' + $dir } $key.SetValue('Path', $updated, $kind) } finally { $key.Close() } # Setting any user variable through .NET broadcasts WM_SETTINGCHANGE, which is what makes new # terminals started from Explorer read the new PATH. The registry write above does not, and # writing Path itself through .NET would flatten a REG_EXPAND_SZ value to REG_SZ. [Environment]::SetEnvironmentVariable('MAXON_INSTALL_REFRESH', '1', 'User') [Environment]::SetEnvironmentVariable('MAXON_INSTALL_REFRESH', $null, 'User') return $true } # The program that started this PowerShell, or $null when that cannot be told. A process ID is reused # once its process exits, so a "parent" created after this process is an unrelated one. function Get-ParentProcessName { try { $self = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $PID" $parent = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $($self.ParentProcessId)" } catch { Write-Verbose "could not look up the process that started this one ($($_.Exception.Message))" return $null } if ($null -eq $parent -or $parent.CreationDate -gt $self.CreationDate) { return $null } return $parent.Name } # The line that appends $dir to PATH in the shell named, or $null for a program that is not a shell # this script can spell one for, such as the terminal an `irm | iex` session runs in. function Get-PathLine([string]$shell, [string]$dir) { switch ($shell) { 'cmd.exe' { return "set ""PATH=%PATH%;$dir""" } { $_ -in 'powershell.exe', 'pwsh.exe' } { return "`$env:Path += ';$($dir.Replace("'", "''"))'" } # Git Bash, MSYS2 and Cygwin, whose PATH holds POSIX spellings of Windows directories. { $_ -in 'bash.exe', 'sh.exe', 'zsh.exe' } { return "export PATH=""`$PATH:`$(cygpath -u '$($dir.Replace("'", "'\''"))')""" } } return $null } if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'this installer is for Windows; on macOS or Linux run: curl -fsSL https://maxon.dev/install.sh | sh' } $architecture = Get-Architecture if ($architecture -ne 'AMD64') { throw "there is no Maxon build for Windows on $architecture" } $root = Get-InstallRoot $bin = Join-Path $root 'bin' $exe = Join-Path $bin 'maxon.exe' $pinned = -not [string]::IsNullOrEmpty($RequestedVersion) if ($pinned) { $RequestedVersion = $RequestedVersion -replace '^v', '' Test-ReleaseVersion $RequestedVersion } if (-not [string]::IsNullOrEmpty($env:MAXON_DOWNLOAD_BASE)) { $base = $env:MAXON_DOWNLOAD_BASE.TrimEnd('/') if (-not $pinned) { throw "MAXON_DOWNLOAD_BASE needs -Version: a mirror has no 'latest' to ask" } } else { $base = "https://github.com/$repo/releases/download" } $release = if ($pinned) { $RequestedVersion } else { Get-LatestVersion } if (-not $Reinstall -and (Test-Path -LiteralPath (Join-Path $root 'stdlib') -PathType Container) -and (Get-InstalledVersion $exe) -eq $release) { if ($pinned) { Say "Maxon $release is already installed in $root (-Force reinstalls it)" } else { Say "Maxon $release, the latest release, is already installed in $root (-Force reinstalls it)" } } else { # A directory that already holds a stdlib\, runtime\ or examples\ of its own is not ours to replace. if (-not (Test-Path -LiteralPath $exe)) { foreach ($entry in @('stdlib', 'runtime', 'examples')) { if (Test-Path -LiteralPath (Join-Path $root $entry)) { throw "$root\$entry exists and $root holds no bin\maxon.exe, so it is not a Maxon install; set MAXON_INSTALL to a new directory" } } } Say "installing Maxon $release for $target into $root" $null = New-Item -ItemType Directory -Path $bin -Force # Leftovers from an earlier run, including an old maxon.exe that was still running then. Get-ChildItem -LiteralPath $bin -Filter 'maxon.exe.old*' -Force | ForEach-Object { Clear-Leftover $_.FullName } Get-ChildItem -LiteralPath $root -Force | Where-Object { $_.Name -like '.staging-*' -or $_.Name -like '.retired-*' } | ForEach-Object { Clear-Leftover $_.FullName } # Staged beside the install so every move below stays on one volume, where it is a rename. $id = [Guid]::NewGuid().ToString('N').Substring(0, 8) $staging = Join-Path $root ".staging-$id" $null = New-Item -ItemType Directory -Path $staging try { $asset = "maxon-$release-$target.zip" $zip = Join-Path $staging $asset $sums = Join-Path $staging 'SHA256SUMS' Get-Download "$base/v$release/$asset" $zip Get-Download "$base/v$release/SHA256SUMS" $sums Test-Checksum $sums $zip $expanded = Join-Path $staging 'expanded' # ZipFile rather than Expand-Archive, which takes minutes over the stdlib on Windows PowerShell 5.1. Add-Type -AssemblyName System.IO.Compression.FileSystem [IO.Compression.ZipFile]::ExtractToDirectory($zip, $expanded) $unpacked = Join-Path $expanded "maxon-$release-$target" foreach ($required in @('maxon.exe', 'stdlib', 'examples')) { if (-not (Test-Path -LiteralPath (Join-Path $unpacked $required))) { throw "$asset does not hold maxon.exe, stdlib\ and examples\ in maxon-$release-$target\" } } # A negative exit code is an NTSTATUS from the loader: the image did not start at all. Any # other status is the compiler running and answering, and older releases answer `version` differently. try { $smoke = Invoke-Captured (Join-Path $unpacked 'maxon.exe') 'version' } catch { throw "the downloaded compiler does not run on this machine ($($_.Exception.Message))" } if ($smoke.ExitCode -lt 0) { throw ('the downloaded compiler does not run on this machine (exit 0x{0:X8})' -f $smoke.ExitCode) } Install-Release $root $unpacked $id } finally { Clear-Leftover $staging } # $null is a release that answers `version` in a form this script does not read. $reported = Get-InstalledVersion $exe if ($null -ne $reported -and $reported -ne $release) { throw "the installed compiler reports $reported, not $release" } Say "installed Maxon $release in $root" } $onSessionPath = $false foreach ($entry in ($env:Path -split ';')) { if ($entry.Trim().TrimEnd('\') -ieq $bin) { $onSessionPath = $true } } if ($UpdatePath) { if (Add-ToUserPath $bin) { Say "added $bin to your user PATH; terminals opened from now on find ``maxon``" } if (-not $onSessionPath) { # Appended, not prepended: the same precedence a new terminal will give it. $env:Path = $env:Path.TrimEnd(';') + ';' + $bin # This session inherited its PATH, so a shell that started it lacks the directory too, and # outlives it: `powershell -c` from cmd.exe ends with this script. $caller = Get-ParentProcessName $line = if ($null -eq $caller) { $null } else { Get-PathLine $caller $bin } if ($null -ne $line) { Say "the $caller that started this PowerShell keeps its own PATH; to run ``maxon`` in it now: $line" } } } elseif (-not $onSessionPath) { Say "$bin is not on your PATH; add it to run ``maxon`` by name" } $found = Get-Command maxon -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $found -and $found.Source -ine $exe) { Say "warning: $($found.Source) comes first on your PATH, so ``maxon`` runs that one" } } # A script block of its own, so nothing it assigns lands in the session `irm | iex` runs in. & { param([string]$RequestedVersion, [bool]$Reinstall, [bool]$UpdatePath) try { Install-Maxon -RequestedVersion $RequestedVersion -Reinstall $Reinstall -UpdatePath $UpdatePath $global:LASTEXITCODE = 0 } catch { $global:LASTEXITCODE = 1 [Console]::Error.WriteLine("maxon-install: $($_.Exception.Message)") } } $Version $Force.IsPresent (-not $NoPathUpdate.IsPresent) Remove-Item -Path Function:\Install-Maxon