diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml new file mode 100644 index 000000000..fae8124f2 --- /dev/null +++ b/.github/workflows/install-script.yml @@ -0,0 +1,82 @@ +name: Install script + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + install-ps1: + name: install.ps1 (${{ matrix.os }}, ${{ matrix.shell }}) + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [windows-latest, windows-11-arm] + shell: [powershell, pwsh] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ matrix.shell }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Parse script + run: | + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile("$PWD\scripts\install.ps1", [ref]$null, [ref]$errors) | Out-Null + if ($errors) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 } + $nonAscii = Select-String -Path scripts\install.ps1 -Pattern '[^\x00-\x7F]' + if ($nonAscii) { $nonAscii | ForEach-Object { Write-Error "Non-ASCII at line $($_.LineNumber)" }; exit 1 } + + # Same path as `irm https://mqlang.org/install.ps1 | iex`, with the script read from the checkout. + - name: Install latest via iex + env: + MQ_HOME: ${{ runner.temp }}\mq-latest + run: | + Get-Content -Raw scripts\install.ps1 | Invoke-Expression + & "$env:MQ_HOME\mq.exe" --version + if ($LASTEXITCODE -ne 0) { exit 1 } + # Installed in the current session without a restart. + mq --version + if ($LASTEXITCODE -ne 0) { exit 1 } + + - name: Check user PATH is persisted once + env: + MQ_HOME: ${{ runner.temp }}\mq-latest + run: | + # Install a second time: the PATH entry must not be duplicated. + Get-Content -Raw scripts\install.ps1 | Invoke-Expression + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') -split ';' + $count = @($userPath | Where-Object { $_.TrimEnd('\') -ieq $env:MQ_HOME.TrimEnd('\') }).Count + if ($count -ne 1) { Write-Error "Expected 1 PATH entry for $env:MQ_HOME, found $count"; exit 1 } + + - name: Install pinned version with mq-dbg + env: + MQ_HOME: ${{ runner.temp }}\mq-pinned + MQ_VERSION: v0.9.3 + MQ_WITH_DEBUG: "1" + MQ_NO_MODIFY_PATH: "1" + run: | + Get-Content -Raw scripts\install.ps1 | Invoke-Expression + foreach ($bin in 'mq', 'mq-dbg') { + $exe = "$env:MQ_HOME\$bin.exe" + if (-not (Test-Path $exe)) { Write-Error "$exe was not installed"; exit 1 } + & $exe --version + if ($LASTEXITCODE -ne 0) { exit 1 } + } + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') -split ';' + if ($userPath -contains $env:MQ_HOME) { Write-Error 'MQ_NO_MODIFY_PATH was ignored'; exit 1 } + + - name: Unknown version fails without exiting the shell + env: + MQ_HOME: ${{ runner.temp }}\mq-missing + MQ_VERSION: v0.0.0 + run: | + $failed = $false + try { Get-Content -Raw scripts\install.ps1 | Invoke-Expression } catch { $failed = $true } + if (-not $failed) { Write-Error 'Installing a nonexistent version should fail'; exit 1 } + if (Test-Path "$env:MQ_HOME\mq.exe") { Write-Error 'mq.exe must not be installed on failure'; exit 1 } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 19f6d7ba1..40e627ffd 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -54,6 +54,7 @@ jobs: - name: Copy install script run: | cp ./scripts/install.sh ./docs + cp ./scripts/install.ps1 ./docs cp ./scripts/install_lsp.sh ./docs cp ./scripts/install_crawler.sh ./docs - name: Deploy diff --git a/README.md b/README.md index 38b87c7d0..439639236 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ curl -sSL https://mqlang.org/install.sh | bash Downloads the latest mq binary for your platform, installs it to `~/.local/bin/`, and updates your shell profile to add mq to your PATH. +On Windows (PowerShell): + +```powershell +powershell -c "irm https://mqlang.org/install.ps1 | iex" +``` + ### Package Managers | Method | Command | diff --git a/docs/_headers b/docs/_headers new file mode 100644 index 000000000..ff4ddd9f9 --- /dev/null +++ b/docs/_headers @@ -0,0 +1,2 @@ +/install.ps1 + Content-Type: text/plain; charset=utf-8 diff --git a/docs/books/src/index.md b/docs/books/src/index.md index d5c079416..33ced77d3 100644 --- a/docs/books/src/index.md +++ b/docs/books/src/index.md @@ -12,6 +12,12 @@ The quickest way to install `mq` is via the install script: curl -sSL https://mqlang.org/install.sh | bash ``` +On Windows, use PowerShell: + +```powershell +powershell -c "irm https://mqlang.org/install.ps1 | iex" +``` + On macOS and Linux, you can also use Homebrew: ```bash diff --git a/docs/books/src/start/install.md b/docs/books/src/start/install.md index f006571a5..6fe6f1e34 100644 --- a/docs/books/src/start/install.md +++ b/docs/books/src/start/install.md @@ -13,6 +13,31 @@ The installer will: - Install it to `~/.local/bin/` - Update your shell profile to add mq to your PATH +### Windows (PowerShell) + +```powershell +powershell -c "irm https://mqlang.org/install.ps1 | iex" +``` + +The installer will: +- Download the latest mq binary for your architecture (x86_64 or arm64) +- Verify its checksum +- Install it to `%USERPROFILE%\.local\bin\` +- Add that directory to your user PATH (restart your terminal afterwards) + +Options are set through environment variables, because `irm | iex` cannot take arguments: + +| Variable | Description | +| ------------------- | ----------------------------------------------------- | +| `MQ_VERSION` | Release tag to install, e.g. `v0.9.3` (default: latest) | +| `MQ_HOME` | Installation directory | +| `MQ_WITH_DEBUG` | Set to `1` to also install the debugger `mq-dbg` | +| `MQ_NO_MODIFY_PATH` | Set to `1` to leave your PATH untouched | + +```powershell +$env:MQ_WITH_DEBUG = "1"; powershell -c "irm https://mqlang.org/install.ps1 | iex" +``` + ## Cargo ```sh diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 000000000..3bcd42b5e --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,141 @@ +# mq installation script for Windows +# +# powershell -c "irm https://mqlang.org/install.ps1 | iex" +# +# Options are passed as environment variables because `irm | iex` cannot take arguments: +# MQ_VERSION Release tag to install (e.g. v0.9.3). Defaults to the latest release. +# MQ_HOME Installation directory. Defaults to $HOME\.local\bin. +# MQ_WITH_DEBUG Set to 1 to also install mq-dbg. +# MQ_NO_MODIFY_PATH Set to 1 to leave the user PATH untouched. +# +# Keep this file ASCII only: Windows PowerShell 5.1 decodes `irm` output without a charset as Latin-1. + +function Install-Mq { + $ErrorActionPreference = 'Stop' + # Invoke-WebRequest is very slow on Windows PowerShell 5.1 while the progress bar is drawn. + $ProgressPreference = 'SilentlyContinue' + + $repo = 'harehare/mq' + $Version = $env:MQ_VERSION + $WithDebug = $env:MQ_WITH_DEBUG -eq '1' + $NoModifyPath = $env:MQ_NO_MODIFY_PATH -eq '1' + $InstallDir = if ($env:MQ_HOME) { $env:MQ_HOME } else { Join-Path $HOME '.local\bin' } + + function Write-Log([string]$Message) { Write-Host "[mq] $Message" -ForegroundColor Green } + function Write-Warn([string]$Message) { Write-Host "[mq] warning: $Message" -ForegroundColor Yellow } + + # Windows PowerShell 5.1 does not enable TLS 1.2 by default. + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch { + Write-Warn "Could not enable TLS 1.2: $($_.Exception.Message)" + } + + function Get-Target { + $arch = $null + try { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } catch { + # Older .NET Framework: PROCESSOR_ARCHITEW6432 is set when a 32-bit process runs on 64-bit Windows. + $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } + } + switch -Regex ($arch) { + '^(X64|AMD64)$' { return 'x86_64-pc-windows-msvc' } + '^(Arm64|ARM64)$' { return 'aarch64-pc-windows-msvc' } + default { throw "Unsupported architecture: $arch" } + } + } + + function Get-ReleaseUrl([string]$FileName) { + if ($Version) { + return "https://github.com/$repo/releases/download/$Version/$FileName" + } + return "https://github.com/$repo/releases/latest/download/$FileName" + } + + # checksums.txt lines look like " /". + function Get-ExpectedChecksum([string]$Checksums, [string]$FileName) { + foreach ($line in ($Checksums -split "`n")) { + $parts = $line.Trim() -split '\s+', 2 + if ($parts.Count -ne 2) { continue } + $name = $parts[1].TrimStart('*') + if ($name -eq $FileName -or $name.EndsWith("/$FileName")) { return $parts[0] } + } + return $null + } + + function Install-Binary([string]$Name, [string]$Target, [string]$Checksums, [string]$TempDir) { + $fileName = "$Name-$Target.exe" + $url = Get-ReleaseUrl $fileName + $tempFile = Join-Path $TempDir $fileName + + Write-Log "Downloading $url" + Invoke-WebRequest -Uri $url -OutFile $tempFile -UseBasicParsing + + $expected = Get-ExpectedChecksum $Checksums $fileName + if (-not $expected) { throw "No checksum found for $fileName" } + $actual = (Get-FileHash -Path $tempFile -Algorithm SHA256).Hash + if ($actual -ne $expected) { + throw "Checksum verification failed for $fileName`nExpected: $expected`nGot: $actual" + } + Write-Log "Checksum verified for $fileName" + + $dest = Join-Path $InstallDir "$Name.exe" + try { + Move-Item -Path $tempFile -Destination $dest -Force + } catch { + throw "Failed to write $dest. If $Name is running, close it and try again. $($_.Exception.Message)" + } + Write-Log "$Name installed to $dest" + } + + function Add-ToUserPath([string]$Dir) { + # Read and write the registry value directly so an existing REG_EXPAND_SZ Path keeps its %VARS%. + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + try { + $current = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $entries = @($current -split ';' | Where-Object { $_ }) + if ($entries | Where-Object { $_.TrimEnd('\') -ieq $Dir.TrimEnd('\') }) { + Write-Log "$Dir is already in your PATH" + } else { + $key.SetValue('Path', (($entries + $Dir) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString) + Write-Log "Added $Dir to your user PATH" + } + } finally { + $key.Close() + } + if (($env:Path -split ';') -notcontains $Dir) { $env:Path = "$env:Path;$Dir" } + } + + $target = Get-Target + Write-Log "Detected system: windows/$target" + + $tempDir = Join-Path ([IO.Path]::GetTempPath()) ("mq-install-" + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + try { + Write-Log 'Downloading checksums' + $checksums = (Invoke-WebRequest -Uri (Get-ReleaseUrl 'checksums.txt') -UseBasicParsing).Content + if ($checksums -is [byte[]]) { $checksums = [Text.Encoding]::UTF8.GetString($checksums) } + + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Install-Binary 'mq' $target $checksums $tempDir + if ($WithDebug) { Install-Binary 'mq-dbg' $target $checksums $tempDir } + } finally { + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + + if (-not $NoModifyPath) { Add-ToUserPath $InstallDir } + + $installed = & (Join-Path $InstallDir 'mq.exe') --version + if ($LASTEXITCODE -ne 0) { throw 'mq was installed but failed to run' } + + Write-Host '' + Write-Log "$installed installed successfully" + Write-Host ' Restart your terminal, then run:' + Write-Host ' mq --version' + Write-Host " echo '# Hello World' | mq '.h'" + Write-Host ' Documentation: https://mqlang.org/' +} + +# `exit` would close the user's shell under `irm | iex`, so failures are reported with throw. +Install-Mq