From ff6d2042d29cd2807ebb3219b9bf96b95ca947cf Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:39:10 +0700 Subject: [PATCH 1/4] fix(windows): support irm | iex install, silence composer stderr banner - install.ps1 now downloads phpvm.ps1 from the repo when it is not sitting next to the script, so `irm .../windows/install.ps1 | iex` works with nothing on disk. This is what linux/install.sh already did; Windows was the outlier and the reason the README had to ask for a two-file manual download. Running from a clone still copies the local file. The download failure path throws instead of calling exit, which under `iex` would close the user's whole session. - phpvm composer: redirect the version probe's stderr. Composer prints its PHP banner and the "run diagnose" hint on stderr, which bypassed the formatting pipeline and leaked unindented into the output. linux/phpvm.sh:1049 already did this; Windows had drifted. devhardiyanto --- README.md | 10 ++++++++-- linux/install.sh | 2 +- linux/phpvm.sh | 2 +- version.txt | 2 +- windows/install.ps1 | 33 ++++++++++++++++++++++++--------- windows/phpvm.ps1 | 6 ++++-- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e40af01..c84bf27 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,18 @@ Install and switch PHP versions on **Windows** (CMD + PowerShell) and **Linux** ### Windows ```powershell -# Download both files (phpvm.ps1 + install.ps1) to the same folder, then: -.\install.ps1 +irm https://raw.githubusercontent.com/devhardiyanto/phpvm/main/windows/install.ps1 | iex ``` Restart your terminal. No admin required. +Or, if you would rather read the script before running it: + +```powershell +# Download both files (phpvm.ps1 + install.ps1) to the same folder, then: +.\install.ps1 +``` + ### Linux ```bash diff --git a/linux/install.sh b/linux/install.sh index e715912..49005fc 100644 --- a/linux/install.sh +++ b/linux/install.sh @@ -6,7 +6,7 @@ set -e -PHPVM_VERSION="1.9.0" +PHPVM_VERSION="1.9.1" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_REPO="https://raw.githubusercontent.com/devhardiyanto/phpvm/main" diff --git a/linux/phpvm.sh b/linux/phpvm.sh index cde3742..cf4923a 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -10,7 +10,7 @@ # phpvm use 8.3.0 # ============================================================================== -PHPVM_VERSION="1.9.0" +PHPVM_VERSION="1.9.1" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_VERSIONS="$PHPVM_DIR/versions" PHPVM_CURRENT="$PHPVM_DIR/current" diff --git a/version.txt b/version.txt index f8e233b..9ab8337 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.0 +1.9.1 diff --git a/windows/install.ps1 b/windows/install.ps1 index 8e5fa7f..d24983c 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -1,13 +1,14 @@ # ============================================================================== # install.ps1 - phpvm installer for Windows # Run as normal user (no admin required) -# Usage: .\install.ps1 +# Usage: irm https://raw.githubusercontent.com/devhardiyanto/phpvm/main/windows/install.ps1 | iex +# or: .\install.ps1 (from a clone, with phpvm.ps1 alongside) # ============================================================================== Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$PHPVM_VERSION = "1.9.0" +$PHPVM_VERSION = "1.9.1" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $PHPVM_BIN = "$PHPVM_DIR\bin" @@ -46,15 +47,29 @@ foreach ($d in @($PHPVM_DIR, "$PHPVM_DIR\versions", $PHPVM_BIN)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } -# 2. Copy phpvm.ps1 -$scriptSrc = Join-Path $PSScriptRoot "phpvm.ps1" -if (-not (Test-Path $scriptSrc)) { - Write-Host " [error] phpvm.ps1 not found next to install.ps1" -ForegroundColor Red - exit 1 +# 2. Obtain phpvm.ps1 — from the clone if we are running off disk, otherwise +# from the repo, so `irm .../install.ps1 | iex` works with nothing local. +# ($PSScriptRoot is empty when this script is piped into iex.) +$PHPVM_REPO = "https://raw.githubusercontent.com/devhardiyanto/phpvm/main" +$scriptSrc = if ($PSScriptRoot) { Join-Path $PSScriptRoot "phpvm.ps1" } else { $null } + +if ($scriptSrc -and (Test-Path $scriptSrc)) { + Copy-Item $scriptSrc "$PHPVM_DIR\phpvm.ps1" -Force + Write-Ok "Copied phpvm.ps1 -> $PHPVM_DIR\phpvm.ps1" +} else { + Write-Step "Downloading phpvm.ps1 ..." + try { + Invoke-WebRequest -Uri "$PHPVM_REPO/windows/phpvm.ps1" ` + -OutFile "$PHPVM_DIR\phpvm.ps1" -UseBasicParsing + } catch { + # throw, not exit: under `irm ... | iex` an `exit` would close the user's + # whole session instead of just aborting the install. + Write-Host " [error] Could not download phpvm.ps1: $_" -ForegroundColor Red + throw "phpvm install aborted." + } + Write-Ok "Downloaded phpvm.ps1 -> $PHPVM_DIR\phpvm.ps1" } -Copy-Item $scriptSrc "$PHPVM_DIR\phpvm.ps1" -Force Unblock-File "$PHPVM_DIR\phpvm.ps1" -Write-Ok "Copied phpvm.ps1 -> $PHPVM_DIR\phpvm.ps1" # 3. Create CMD launcher (phpvm.cmd) - works in CMD and PowerShell $cmdLauncher = '@echo off diff --git a/windows/phpvm.ps1 b/windows/phpvm.ps1 index dacae99..aeb1df9 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -15,7 +15,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.9.0" +$PHPVM_VERSION = "1.9.1" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current" @@ -1302,7 +1302,9 @@ php "$composerPhar" %* Write-Ok " phar : $composerPhar" Write-Ok " shim : $composerBat" Write-Host "" - & $info.Exe $composerPhar --version | ForEach-Object { Write-Host " $_" } + # 2>$null: composer writes its PHP-version banner and the "run diagnose" hint + # to stderr, which would bypass this pipeline and print unindented. + & $info.Exe $composerPhar --version 2>$null | ForEach-Object { Write-Host " $_" } Write-Host "" Write-Dim "Composer follows your active PHP version - no need to re-run after 'phpvm use'." } From ee4488a6a530dad705490ea758612c7e3aed58aa Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:32:41 +0700 Subject: [PATCH 2/4] fix(linux): stop install leaking cwd and job-control noise into the user's shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both surfaced testing 1.9.0 on WSL2. - phpvm.sh is sourced, so phpvm_install's `cd "$src_dir"` moved the *user's own* shell into the build directory — which the cleanup step then deleted, leaving them sitting in a dangling cwd. None of the failure paths cd'd back either. The build now runs in a subshell, so the cd cannot escape at all rather than being undone at four separate return points. - The spinner's background job printed the interactive shell's job-control notices ("[3] 2015", "[3] + done ...") around every build step. Turn the monitor off while the job runs and restore it exactly as found. CI never saw this: job control is off in non-interactive shells. Windows is unaffected — Invoke-Install extracts a zip and never changes directory. devhardiyanto --- linux/phpvm.sh | 82 +++++++++++++++++++++++++++------------ tests/linux/commands.bats | 39 +++++++++++++++++++ windows/phpvm.ps1 | 33 +++++++++++++++- 3 files changed, 128 insertions(+), 26 deletions(-) diff --git a/linux/phpvm.sh b/linux/phpvm.sh index cf4923a..15cfd44 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -312,11 +312,17 @@ _phpvm_run_logged() { return $? fi + # phpvm.sh is sourced into an interactive shell, where job control is on and + # would print "[3] 2015" / "[3] + done ..." around our background build. + # Turn the monitor off for the duration and put it back exactly as we found it. + local had_monitor=0 + case "$-" in *m*) had_monitor=1; set +m ;; esac + "$@" >> "$PHPVM_LOG" 2>&1 & local pid=$! # Ctrl+C must take the build down with us, not orphan it. - trap 'kill "$pid" 2>/dev/null; printf "\r\033[K" >&2; trap - INT; return 130' INT + trap 'kill "$pid" 2>/dev/null; printf "\r\033[K" >&2; [[ $had_monitor -eq 1 ]] && set -m; trap - INT; return 130' INT # Plain ASCII frames + explicit index: ${var:i++:1} is a bashism that bites # in zsh, and Unicode braille spinners mangle on older terminals. @@ -334,6 +340,7 @@ _phpvm_run_logged() { wait "$pid" local rc=$? trap - INT + [[ $had_monitor -eq 1 ]] && set -m printf '\r\033[K' >&2 elapsed=$((SECONDS - start)) @@ -493,30 +500,26 @@ phpvm_install() { "--with-pear" ) - cd "$src_dir" || { - _err "Could not enter source directory: $src_dir" - rm -rf "$target" - return 1 - } - ./buildconf --force &>>"$PHPVM_LOG" 2>&1 || true # needed only for git checkouts - _phpvm_run_logged "Configuring" ./configure "${configure_opts[@]}" || { - _err "Configure failed. See log: $PHPVM_LOG" - rm -rf "$target" - return 1 - } - - # Build local cpus cpus=$(_phpvm_cpus) - _phpvm_run_logged "Building with $cpus cores" make -j"$cpus" || { - _err "Build failed. See log: $PHPVM_LOG" - rm -rf "$target" - return 1 - } - # Install - _phpvm_run_logged "Installing" make install || { - _err "Install failed. See log: $PHPVM_LOG" + # The whole build runs in a subshell so the `cd` cannot escape: phpvm.sh is + # sourced, so a bare cd here would strand the user's own shell in the build + # directory — which is then deleted, leaving them in a dangling cwd. + ( + cd "$src_dir" || { _err "Could not enter source directory: $src_dir"; exit 1; } + + ./buildconf --force &>>"$PHPVM_LOG" 2>&1 || true # needed only for git checkouts + + _phpvm_run_logged "Configuring" ./configure "${configure_opts[@]}" \ + || { _err "Configure failed. See log: $PHPVM_LOG"; exit 1; } + + _phpvm_run_logged "Building with $cpus cores" make -j"$cpus" \ + || { _err "Build failed. See log: $PHPVM_LOG"; exit 1; } + + _phpvm_run_logged "Installing" make install \ + || { _err "Install failed. See log: $PHPVM_LOG"; exit 1; } + ) || { rm -rf "$target" return 1 } @@ -538,9 +541,40 @@ phpvm_install() { # Activate the freshly built version right away, unless opted out. if [[ $no_use -eq 1 ]]; then _dim "Not switching (--no-use). Run: phpvm use $ver" - return 0 + else + phpvm_use "$ver" fi - phpvm_use "$ver" + + _phpvm_older_patch_hint "$ver" +} + +# `phpvm install 8` resolves to the newest patch and installs it alongside any +# older patch of the same line. Point that out rather than removing it: another +# project may still pin the old patch in .phpvmrc. +_phpvm_older_patches() { + local ver="$1" + [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 0 + [[ -d "$PHPVM_VERSIONS" ]] || return 0 + + local line="${ver%.*}" name + find "$PHPVM_VERSIONS" -mindepth 1 -maxdepth 1 -type d -name "$line.*" 2>/dev/null \ + | while read -r d; do + name=$(basename "$d") + [[ "$name" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue + [[ "$name" == "$ver" ]] && continue + # Keep only patches strictly below $ver. + [[ "$(printf '%s\n%s' "$name" "$ver" | sort -V | head -1)" == "$name" ]] && echo "$name" + done | sort -V +} + +_phpvm_older_patch_hint() { + local ver="$1" older newest + older=$(_phpvm_older_patches "$ver") + [[ -z "$older" ]] && return 0 + + newest=$(echo "$older" | tail -1) + _dim "Older patch of ${ver%.*} still installed: $(echo "$older" | paste -sd ', ')" + _dim "Remove it with: phpvm uninstall $newest" } # ============================================================================== diff --git a/tests/linux/commands.bats b/tests/linux/commands.bats index 6c3d86d..3db9d5b 100644 --- a/tests/linux/commands.bats +++ b/tests/linux/commands.bats @@ -310,3 +310,42 @@ EOF [ "$status" -ne 0 ] [[ "$output" == *"Usage: phpvm install"* ]] } + +# ---------- older-patch hint ---------- + +@test "older_patches: lists only lower patches of the same minor line" { + mkdir -p "$PHPVM_VERSIONS"/{8.5.6,8.5.8,8.5.10,8.3.31,7.4.33} + run _phpvm_older_patches 8.5.8 + [ "$status" -eq 0 ] + [ "$output" = "8.5.6" ] +} + +@test "older_patches: sorts numerically, not lexically" { + mkdir -p "$PHPVM_VERSIONS"/{8.5.2,8.5.10,8.5.11} + run _phpvm_older_patches 8.5.11 + [ "$status" -eq 0 ] + [ "$output" = "8.5.2 +8.5.10" ] +} + +@test "older_patches: does not treat 8.50.x as part of the 8.5 line" { + mkdir -p "$PHPVM_VERSIONS"/{8.50.1,8.5.9} + run _phpvm_older_patches 8.5.9 + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "older_patches: silent when it is the only patch of its line" { + mkdir -p "$PHPVM_VERSIONS"/{8.5.8,8.3.31} + run _phpvm_older_patches 8.5.8 + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "older_patch_hint: names the version to uninstall" { + mkdir -p "$PHPVM_VERSIONS"/{8.5.6,8.5.8} + run _phpvm_older_patch_hint 8.5.8 + [ "$status" -eq 0 ] + [[ "$output" == *"Older patch of 8.5 still installed: 8.5.6"* ]] + [[ "$output" == *"phpvm uninstall 8.5.6"* ]] +} diff --git a/windows/phpvm.ps1 b/windows/phpvm.ps1 index aeb1df9..9ebb9cc 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -490,9 +490,38 @@ function Invoke-Install ([string]$ver, [string]$flag) { # Activate the freshly installed version right away, unless opted out. if ($noUse) { Write-Dim "Not switching (--no-use). Run: phpvm use $ver" - return + } else { + Invoke-Use $ver } - Invoke-Use $ver + + Show-OlderPatchHint $ver +} + +# `phpvm install 8` resolves to the newest patch and installs it alongside any +# older patch of the same line. Point that out rather than removing it: another +# project may still pin the old patch in .phpvmrc. +function Get-OlderPatch ([string]$ver) { + if ($ver -notmatch '^\d+\.\d+\.\d+$') { return @() } + if (-not (Test-Path $VERSIONS_DIR)) { return @() } + + $parts = $ver -split '\.' + $line = "$($parts[0]).$($parts[1])" + + return @( + Get-ChildItem $VERSIONS_DIR -Directory -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name | + Where-Object { $_ -match '^\d+\.\d+\.\d+$' -and $_ -like "$line.*" } | + Where-Object { [version]$_ -lt [version]$ver } | + Sort-Object { [version]$_ } + ) +} + +function Show-OlderPatchHint ([string]$ver) { + $older = Get-OlderPatch $ver + if ($older.Count -eq 0) { return } + + Write-Dim "Older patch of $(($ver -split '\.')[0..1] -join '.') still installed: $($older -join ', ')" + Write-Dim "Remove it with: phpvm uninstall $($older[-1])" } function Invoke-Use ([string]$ver) { From d624011e7dd219cce0ff2539da32f9be54843f6c Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:36:28 +0700 Subject: [PATCH 3/4] fix(windows): keep install.ps1 ASCII-only An em-dash slipped into a comment, tripping PSScriptAnalyzer's PSUseBOMForUnicodeEncodedFile. The rest of the Windows scripts are ASCII. devhardiyanto --- windows/install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows/install.ps1 b/windows/install.ps1 index d24983c..3958136 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -47,7 +47,7 @@ foreach ($d in @($PHPVM_DIR, "$PHPVM_DIR\versions", $PHPVM_BIN)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } -# 2. Obtain phpvm.ps1 — from the clone if we are running off disk, otherwise +# 2. Obtain phpvm.ps1 - from the clone if we are running off disk, otherwise # from the repo, so `irm .../install.ps1 | iex` works with nothing local. # ($PSScriptRoot is empty when this script is piped into iex.) $PHPVM_REPO = "https://raw.githubusercontent.com/devhardiyanto/phpvm/main" From 01b556ff358d2537184fdd11875288fa792a8671 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:41:34 +0700 Subject: [PATCH 4/4] test(windows): cover Get-OlderPatch Linux got five bats tests for the patch-line selection; Windows had none, so the same off-by-one risks (lexical sort, 8.50.x matching the 8.5 line) were untested there. devhardiyanto --- tests/windows/Install.Tests.ps1 | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/windows/Install.Tests.ps1 b/tests/windows/Install.Tests.ps1 index eb6d822..37961c8 100644 --- a/tests/windows/Install.Tests.ps1 +++ b/tests/windows/Install.Tests.ps1 @@ -75,3 +75,34 @@ Describe 'Invoke-Install --no-use parsing' { Should -Invoke Resolve-PHPURL -Times 0 } } + +Describe 'Get-OlderPatch' { + BeforeAll { + . $PSScriptRoot/Common.ps1 + + # Build a fake $VERSIONS_DIR. 8.50.1 is here on purpose: it must not be + # mistaken for part of the 8.5 line. + $fakeVersions = Join-Path $TestDrive 'versions' + foreach ($v in @('7.4.33', '8.3.31', '8.5.2', '8.5.6', '8.5.8', '8.5.10', '8.5.11', '8.50.1')) { + New-Item -ItemType Directory -Path (Join-Path $fakeVersions $v) -Force | Out-Null + } + # Reassign $VERSIONS_DIR; functions read it dynamically. + $VERSIONS_DIR = $fakeVersions + } + + It 'Lists only lower patches of the same minor line' { + Get-OlderPatch '8.5.8' | Should -Be @('8.5.2', '8.5.6') + } + + It 'Sorts numerically, not lexically' { + Get-OlderPatch '8.5.11' | Should -Be @('8.5.2', '8.5.6', '8.5.8', '8.5.10') + } + + It 'Does not treat 8.50.x as part of the 8.5 line' { + Get-OlderPatch '8.50.1' | Should -BeNullOrEmpty + } + + It 'Returns nothing when it is the only patch of its line' { + Get-OlderPatch '8.3.31' | Should -BeNullOrEmpty + } +}