diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 64665ec..7662e0c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -50,3 +50,15 @@ jobs: steps: - uses: actions/checkout@v4 - run: docker run --rm --network none -v "$PWD:/src:ro" python:3.12-slim python /src/tests/installer.py + + windows-installer: + name: Windows installer + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Windows PowerShell 5.1 + shell: powershell + run: ./tests/windows_installer.ps1 + - name: PowerShell 7 + shell: pwsh + run: ./tests/windows_installer.ps1 diff --git a/README.md b/README.md index ea1e71a..d0654d8 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,10 @@ Please run `envx config migrate` to migrate your config file to the new format. curl -fsSL get.envx.sh | bash ``` -For windows users: -Download the binary from [this page](https://github.com/envx-project/cli/releases/latest), then you can run that binary as an application. +```powershell +# Windows (PowerShell, no administrator rights needed) +powershell -c "irm https://raw.githubusercontent.com/envx-project/cli/main/install.ps1 | iex" +``` For more detailed instructions, see [windows installation](https://github.com/envx-project/cli/blob/main/windows-installation.md) @@ -75,7 +77,7 @@ Commands: shell Open a subshell with envx variables available unlink Unlink the current project unset Unset (delete) an environment variable - update Attempt to self-update envx using the installation script. Fails on Windows + update Self-update envx using the installation script upload If your key is not in the database, use this command to upload it variables Get all environment variables for the current configured directory version Fancy, pretty-printed version information diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..fdc6bc1 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,125 @@ +# Install a published envx release on Windows. +# +# powershell -c "irm https://raw.githubusercontent.com/envx-project/cli/main/install.ps1 | iex" +# +# Installs per user (no administrator rights) and adds the folder to the user PATH. +# Environment: ENVX_VERSION, ENVX_INSTALL_DIR, ENVX_PLATFORM (msvc or gnu), +# ENVX_BASE_URL, ENVX_NO_MODIFY_PATH=1, ENVX_UNINSTALL=1. +# Compatible with Windows PowerShell 5.1 and PowerShell 7. + +& { + $ErrorActionPreference = 'Stop' + # Invoke-WebRequest renders progress very slowly on Windows PowerShell 5.1. + $ProgressPreference = 'SilentlyContinue' + # Windows PowerShell 5.1 may default to TLS 1.0. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + function Fail($message) { throw "envx: $message" } + $onWindows = $PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows + + $installDir = $env:ENVX_INSTALL_DIR + if (-not $installDir) { + if (-not $env:LOCALAPPDATA) { Fail 'LOCALAPPDATA is not set; set ENVX_INSTALL_DIR' } + $installDir = Join-Path $env:LOCALAPPDATA 'Programs\envx' + } + $exe = Join-Path $installDir 'envx.exe' + + function Update-UserPath([bool]$add) { + if (-not $onWindows -or $env:ENVX_NO_MODIFY_PATH -eq '1') { return } + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @(if ($current) { $current -split ';' | Where-Object { $_ } }) + $present = $entries | Where-Object { $_.TrimEnd('\') -ieq $installDir.TrimEnd('\') } + if ($add -and -not $present) { + [Environment]::SetEnvironmentVariable('Path', (($entries + $installDir) -join ';'), 'User') + $env:Path = "$env:Path;$installDir" + Write-Host "Added $installDir to your user PATH. Restart other terminals to pick it up." + } elseif (-not $add -and $present) { + $kept = $entries | Where-Object { $_.TrimEnd('\') -ine $installDir.TrimEnd('\') } + [Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User') + } + } + + if ($env:ENVX_UNINSTALL -eq '1') { + if (Test-Path -LiteralPath $exe) { Remove-Item -LiteralPath $exe -Force } + Remove-Item -LiteralPath "$exe.old" -Force -ErrorAction SilentlyContinue + Update-UserPath $false + Write-Host 'Removed envx' + return + } + + $baseUrl = if ($env:ENVX_BASE_URL) { $env:ENVX_BASE_URL.TrimEnd('/') } else { 'https://github.com/envx-project/cli/releases' } + if (-not $baseUrl.StartsWith('https://')) { Fail 'Release downloads require an HTTPS base URL' } + + $arch = $env:PROCESSOR_ARCHITECTURE + if ($arch -and $arch -notin @('AMD64', 'ARM64')) { + # ARM64 Windows runs the x86_64 build under emulation; 32-bit has no build. + Fail "No published build for $arch Windows; see https://github.com/envx-project/cli/releases" + } + $platform = if ($env:ENVX_PLATFORM) { $env:ENVX_PLATFORM } else { 'msvc' } + if ($platform -notin @('msvc', 'gnu')) { Fail 'ENVX_PLATFORM must be msvc or gnu' } + $target = "x86_64-pc-windows-$platform" + + $version = $env:ENVX_VERSION + if (-not $version) { + # /latest redirects to /tag/vX.Y.Z; read the tag without following it. + $request = [Net.HttpWebRequest]::Create("$baseUrl/latest") + $request.Method = 'HEAD' + $request.AllowAutoRedirect = $false + $response = $request.GetResponse() + try { $location = $response.Headers['Location'] } finally { $response.Close() } + if (-not $location) { Fail 'Could not determine the latest release' } + $version = ($location -split '/')[-1] + } + $version = $version -replace '^v', '' + if ($version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$') { Fail "Invalid release version: $version" } + + $asset = "envx-$version-$target.zip" + $url = "$baseUrl/download/v$version/$asset" + $work = Join-Path ([IO.Path]::GetTempPath()) ("envx-install-" + [Guid]::NewGuid()) + New-Item -ItemType Directory -Path $work | Out-Null + try { + $archive = Join-Path $work $asset + try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $archive } catch { Fail "Release download failed: $url" } + + # Older published releases predate checksum sidecars. Never silently + # downgrade verification for a new release or on a mismatch. + $checksum = Join-Path $work 'checksum' + $haveChecksum = $true + try { Invoke-WebRequest -UseBasicParsing -Uri "$url.sha256" -OutFile $checksum } catch { $haveChecksum = $false } + if ($haveChecksum) { + $expected = ((Get-Content -LiteralPath $checksum -TotalCount 1) -split '\s+')[0] + if ($expected -notmatch '^[0-9a-fA-F]{64}$') { Fail 'Invalid SHA256 checksum' } + $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash + if ($actual -ine $expected) { Fail 'Release checksum mismatch; installation aborted' } + } else { + $parts = $version -split '\.' + if ([int]$parts[0] -lt 2 -or ([int]$parts[0] -eq 2 -and [int]$parts[1] -le 13)) { + Write-Warning "Legacy release $version has no SHA256 sidecar; relying on HTTPS transport." + } else { + Fail 'Release checksum unavailable; installation aborted' + } + } + + # Extract only envx.exe; never unpack archive paths into the install folder. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $staged = Join-Path $work 'envx.exe' + $zip = [IO.Compression.ZipFile]::OpenRead($archive) + try { + $entry = $zip.Entries | Where-Object { $_.FullName -eq 'envx.exe' } | Select-Object -First 1 + if (-not $entry -or $entry.Length -eq 0) { Fail 'Release archive contains no executable' } + [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $staged, $true) + } finally { $zip.Dispose() } + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + # A running envx.exe cannot be overwritten, but it can be renamed aside. + Remove-Item -LiteralPath "$exe.old" -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $exe) { Move-Item -LiteralPath $exe -Destination "$exe.old" -Force } + Move-Item -LiteralPath $staged -Destination $exe -Force + Remove-Item -LiteralPath "$exe.old" -Force -ErrorAction SilentlyContinue + } finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host "Installed envx $version to $exe" + Update-UserPath $true +} diff --git a/skills/envx/SKILL.md b/skills/envx/SKILL.md index ba11db3..7665de3 100644 --- a/skills/envx/SKILL.md +++ b/skills/envx/SKILL.md @@ -21,7 +21,13 @@ Verify capability quickly: `envx whoami` (prints your key fingerprint + uuid) an curl -fsSL https://get.envx.sh | sh # installs to /usr/local/bin (needs sudo) ``` -No-sudo / locked-down machines: grab the release tarball for your target from the [`envx-project/cli`](https://github.com/envx-project/cli/releases) GitHub releases (e.g. `envx-2.13.0-aarch64-apple-darwin.tar.gz`), extract, and drop the `envx` binary somewhere on `PATH` such as `~/.local/bin`. Self-update later with `envx update` (re-runs the install script; fails on Windows). +Windows (PowerShell; per-user, no admin; adds itself to the user `PATH`): + +```powershell +powershell -c "irm https://raw.githubusercontent.com/envx-project/cli/main/install.ps1 | iex" +``` + +No-sudo Unix machines: `curl -fsSL get.envx.sh | sh -s -- --bin-dir ~/.local/bin`. Self-update later with `envx update` (re-runs the install script on every platform). Confirm: `envx --version` → `envx 2.13.0`. diff --git a/src/commands/update.rs b/src/commands/update.rs index 07ca44b..92919ad 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -9,19 +9,15 @@ use crate::utils::config::Config; use super::*; -/// Attempt to self-update envx using the installation script. Fails on Windows. +/// Self-update envx using the installation script #[derive(Parser)] pub struct Args {} +#[cfg(not(target_os = "windows"))] +const INSTALLER_URL: &str = "https://get.envx.sh"; #[cfg(target_os = "windows")] -pub async fn command(_args: Args, _config: &mut Config) -> Result<()> { - use anyhow::bail; - - eprintln!("Self-update is not supported on Windows"); - eprintln!("Read the installation instructions at https://github.com/envx-project/cli/blob/main/windows-installation.md"); - - bail!("Self-update is not supported on Windows") -} +const INSTALLER_URL: &str = + "https://raw.githubusercontent.com/envx-project/cli/main/install.ps1"; #[derive(Default, serde::Serialize, serde::Deserialize)] pub struct UpdateCheck { @@ -66,7 +62,6 @@ pub async fn check_update(force: bool) -> anyhow::Result { Ok(latest_version.to_string()) } -#[cfg(not(target_os = "windows"))] pub async fn command(_args: Args, _config: &mut Config) -> Result<()> { let latest_version = check_update(true).await?; @@ -92,7 +87,7 @@ pub async fn command(_args: Args, _config: &mut Config) -> Result<()> { .connect_timeout(std::time::Duration::from_secs(3)) .timeout(std::time::Duration::from_secs(15)) .build()? - .get("https://get.envx.sh") + .get(INSTALLER_URL) .send() .await? .error_for_status()? @@ -125,6 +120,39 @@ async fn run_installer(script: &str) -> Result<()> { Ok(()) } +/// Reinstall into the folder of the running executable. The installer renames +/// the in-use envx.exe aside, which Windows allows, before placing the update. +#[cfg(target_os = "windows")] +async fn run_installer(script: &str) -> Result<()> { + let exe = std::env::current_exe()?; + let dir = exe.parent().context("Executable has no parent folder")?; + let path = std::env::temp_dir() + .join(format!("envx-install-{}.ps1", uuid::Uuid::new_v4())); + std::fs::write(&path, script)?; + let status = tokio::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + ]) + .arg(&path) + .env("ENVX_INSTALL_DIR", dir) + .env("ENVX_NO_MODIFY_PATH", "1") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await; + let _ = std::fs::remove_file(&path); + let status = status?; + if !status.success() { + bail!("Update command failed with status: {}", status); + } + println!("Command executed successfully."); + Ok(()) +} + #[cfg(all(test, not(target_os = "windows")))] mod tests { use super::*; diff --git a/tests/windows_installer.ps1 b/tests/windows_installer.ps1 new file mode 100644 index 0000000..4f6b13c --- /dev/null +++ b/tests/windows_installer.ps1 @@ -0,0 +1,22 @@ +# Exercise the documented `irm ... | iex` path against the latest published release. +$ErrorActionPreference = 'Stop' +$env:ENVX_INSTALL_DIR = Join-Path ([IO.Path]::GetTempPath()) ("envx-bin-" + [Guid]::NewGuid()) +$script = Get-Content -Raw (Join-Path $PSScriptRoot '..\install.ps1') +function UserPath { [Environment]::GetEnvironmentVariable('Path', 'User') } + +$script | Invoke-Expression +& (Join-Path $env:ENVX_INSTALL_DIR 'envx.exe') --version +if ($LASTEXITCODE -ne 0) { throw 'installed envx did not run' } +if ((UserPath) -notlike "*$env:ENVX_INSTALL_DIR*") { throw 'user PATH not updated' } + +# Reinstall while the old binary exists; the entry must not be duplicated. +$script | Invoke-Expression +if (Test-Path (Join-Path $env:ENVX_INSTALL_DIR 'envx.exe.old')) { throw 'stale .old left behind' } +if (((UserPath) -split ';' | Where-Object { $_ -eq $env:ENVX_INSTALL_DIR }).Count -ne 1) { throw 'PATH entry duplicated' } + +$env:ENVX_UNINSTALL = '1' +$script | Invoke-Expression +$env:ENVX_UNINSTALL = '' +if (Test-Path (Join-Path $env:ENVX_INSTALL_DIR 'envx.exe')) { throw 'still installed' } +if ((UserPath) -like "*$env:ENVX_INSTALL_DIR*") { throw 'user PATH not cleaned' } +'PASS: Windows installer install, reinstall, uninstall' diff --git a/windows-installation.md b/windows-installation.md index 24d141f..7555aae 100644 --- a/windows-installation.md +++ b/windows-installation.md @@ -2,29 +2,44 @@ ## Installation -1. Download the latest release from [here](https://github.com/envx-project/cli/releases/latest) - - Choose the `x86_64-pc-windows-msvc.zip` file - - ![image](./assets/releases.png) -2. Unzip the file -3. Create a new folder called `envx` in your `C:\` directory -4. Copy the `envx.exe` file from the unzipped folder to the `envx` folder -5. Add the `envx` folder to your PATH environment variable (instructions [here](https://www.architectryan.com/2018/03/17/add-to-the-path-on-windows-10/)) +Run this in PowerShell or Command Prompt: + +```powershell +powershell -c "irm https://raw.githubusercontent.com/envx-project/cli/main/install.ps1 | iex" +``` + +The script downloads the latest release, verifies its SHA256 checksum, installs +`envx.exe` to `%LOCALAPPDATA%\Programs\envx` (no administrator rights needed), +and adds that folder to your user `PATH`. Open a new terminal afterwards. + +Update later with `envx update`. + +Environment variables customise the install: -## Portable Installation +| Variable | Effect | +| --- | --- | +| `ENVX_VERSION` | Install a specific release, e.g. `2.16.0` | +| `ENVX_INSTALL_DIR` | Install somewhere other than `%LOCALAPPDATA%\Programs\envx` | +| `ENVX_PLATFORM` | `msvc` (default) or `gnu` build | +| `ENVX_NO_MODIFY_PATH` | Set to `1` to leave `PATH` unchanged | +| `ENVX_UNINSTALL` | Set to `1` to remove envx and its `PATH` entry | -1. Download the latest release from [here](https://github.com/envx-project/cli/releases/latest) - - Choose the `x86_64-pc-windows-msvc.zip` file +For example, to uninstall: + +```powershell +$env:ENVX_UNINSTALL = '1'; irm https://raw.githubusercontent.com/envx-project/cli/main/install.ps1 | iex +``` + +## Manual installation + +1. Download `x86_64-pc-windows-msvc.zip` from the [latest release](https://github.com/envx-project/cli/releases/latest) - ![image](./assets/releases.png) -2. Unzip the file -3. Open a terminal and navigate to the unzipped folder -4. Run `envx.exe` to start the program +2. Unzip it and move `envx.exe` to a folder of your choice +3. Add that folder to your `PATH` environment variable ## Troubleshooting -### Error: The term 'envx' is not recognized as the name of a cmdlet, function, script file, or operable program - -If you get this error, you probably tried to run the program from the global scope when you followed the portable installation instructions. -To fix this, you can either: +### The term 'envx' is not recognized as the name of a cmdlet, function, script file, or operable program -- Run the program from the local scope by navigating to the `envx` folder and running `envx.exe` -- Add the `envx` folder to your PATH environment variable (instructions [here](https://www.architectryan.com/2018/03/17/add-to-the-path-on-windows-10/)) +The terminal was opened before `PATH` changed. Open a new terminal. If it still +fails, check that the install folder is listed in your user `PATH`.