From e6dae3506dc89202c0df281da48e7a590d17a248 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto Date: Thu, 18 Jun 2026 23:03:10 +0700 Subject: [PATCH 1/2] feat: v1.8.0 - UX fixes, partial-version resolve, auto-use, did-you-mean, uninstaller Cross-platform release addressing user UX feedback. Fixes: - Linux color helpers use printf instead of `echo -e`, so a trailing `\` in the apt/dnf dependency block no longer merges with the \033[0m reset and leaks `\033[0m` into the output. - `phpvm use` only warns about persisting to a shell rc when phpvm is not already sourced there, instead of on every call. - `php --version` / `composer --version` output is indented to match the surrounding 2-space style (use, current, composer). Features: - `phpvm install 8` resolves to the latest 8.x and `phpvm install 8.3` to the latest 8.3.x (Linux via php.net JSON; Windows bare-major added to Resolve-LatestPatch). - A successful install now auto-activates the version (no separate `use`). - Unknown commands get a Levenshtein-based "Did you mean ...?" suggestion instead of dumping the full help. - New uninstall.sh / uninstall.ps1 reverse the installers (--keep-versions, --yes); does not revert Windows ExecutionPolicy. Also: .gitattributes pins *.sh/*.bats to LF; bats + Pester coverage for the new resolver, did-you-mean, and Levenshtein; README; version bump to 1.8.0. --- .gitattributes | 9 ++ README.md | 40 +++++++- linux/install.sh | 2 +- linux/phpvm.sh | 134 +++++++++++++++++++++++--- linux/uninstall.sh | 133 +++++++++++++++++++++++++ tests/linux/commands.bats | 52 ++++++++++ tests/windows/PureFunctions.Tests.ps1 | 59 ++++++++++++ version.txt | 2 +- windows/install.ps1 | 2 +- windows/phpvm.ps1 | 80 ++++++++++++--- windows/uninstall.ps1 | 113 ++++++++++++++++++++++ 11 files changed, 592 insertions(+), 34 deletions(-) create mode 100644 .gitattributes create mode 100644 linux/uninstall.sh create mode 100644 windows/uninstall.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2dfdb17 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Shell scripts and bats tests must stay LF — CRLF breaks them on Linux/macOS +# (`bad interpreter: /usr/bin/env bash^M`). +*.sh text eol=lf +*.bats text eol=lf + +# PowerShell is fine with CRLF on Windows; keep it consistent there. +*.ps1 text eol=crlf +*.psd1 text eol=crlf +*.cmd text eol=crlf diff --git a/README.md b/README.md index e07e359..64da4a0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,34 @@ source ~/.phpvm-src/linux/phpvm.sh --- +## Uninstall + +Removes `~/.phpvm` and the phpvm entry from your shell config (PATH on Windows, +the `# phpvm` source block on Linux). It does **not** touch anything else. + +### Windows + +```powershell +.\uninstall.ps1 # removes everything (asks to confirm) +.\uninstall.ps1 -KeepVersions # keep built PHP versions +.\uninstall.ps1 -Yes # no prompt +``` + +### Linux + +```bash +curl -fsSL https://raw.githubusercontent.com/devhardiyanto/phpvm/main/linux/uninstall.sh | bash -s -- --yes +# or, from a clone: +bash linux/uninstall.sh # removes everything (asks to confirm) +bash linux/uninstall.sh --keep-versions # keep built PHP versions +``` + +By default the uninstaller removes all built PHP versions too; pass +`--keep-versions` / `-KeepVersions` to retain them. The `phpvm` function stays +loaded in the current shell until you restart it. + +--- + ## Usage ### Version Management @@ -38,7 +66,8 @@ source ~/.phpvm-src/linux/phpvm.sh ```bash phpvm install 8.3.0 # install a specific PHP version phpvm install 8.3 # install latest 8.3.x patch (auto-resolves) -phpvm install 7.3 # works for older lines (7.x, 5.x) +phpvm install 8 # install latest 8.x patch (e.g. 8.5.x) +phpvm install 7.4 # works for older lines (7.x, 5.x) phpvm use 8.3.0 # switch active version phpvm list # list installed versions phpvm current # show active version @@ -47,6 +76,9 @@ phpvm which # path to active php binary phpvm ini # open php.ini in editor ``` +A successful `phpvm install` automatically activates the freshly installed +version, so you can skip a separate `phpvm use` for the common case. + ### Auto-Switch with `.phpvmrc` (Windows) Drop a `.phpvmrc` file in your project root containing the PHP version you want: @@ -188,10 +220,12 @@ sudo apt-get install -y \ phpvm/ ├── windows/ │ ├── phpvm.ps1 # main script -│ └── install.ps1 # installer +│ ├── install.ps1 # installer +│ └── uninstall.ps1 # uninstaller ├── linux/ │ ├── phpvm.sh # main script (sourced in .bashrc) -│ └── install.sh # curl installer +│ ├── install.sh # curl installer +│ └── uninstall.sh # curl uninstaller └── README.md ``` diff --git a/linux/install.sh b/linux/install.sh index 84edbba..abda429 100644 --- a/linux/install.sh +++ b/linux/install.sh @@ -6,7 +6,7 @@ set -e -PHPVM_VERSION="1.7.2" +PHPVM_VERSION="1.8.0" 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 d5628b9..d239036 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -10,7 +10,7 @@ # phpvm use 8.3.0 # ============================================================================== -PHPVM_VERSION="1.7.2" +PHPVM_VERSION="1.8.0" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_VERSIONS="$PHPVM_DIR/versions" PHPVM_CURRENT="$PHPVM_DIR/current" @@ -21,11 +21,15 @@ PHPVM_LAST_CHECK="$PHPVM_DIR/.last_update_check" PHPVM_CHECK_INTERVAL=86400 # 24 hours # ── Colors ──────────────────────────────────────────────────────────────────── -_ok() { echo -e " \033[32m$*\033[0m"; } -_err() { echo -e " \033[31m[error] $*\033[0m" >&2; } -_step() { echo -e " \033[36m> $*\033[0m"; } -_warn() { echo -e " \033[33m[warn] $*\033[0m"; } -_dim() { echo -e " \033[90m$*\033[0m"; } +# printf (not echo -e): the message is passed through %s so backslashes in the +# text — e.g. the trailing `\` of a multi-line shell command — stay literal and +# never merge with the trailing \033[0m reset. echo -e merged them, leaking +# `\033[0m` into the output on some shells. +_ok() { printf ' \033[32m%s\033[0m\n' "$*"; } +_err() { printf ' \033[31m[error] %s\033[0m\n' "$*" >&2; } +_step() { printf ' \033[36m> %s\033[0m\n' "$*"; } +_warn() { printf ' \033[33m[warn] %s\033[0m\n' "$*"; } +_dim() { printf ' \033[90m%s\033[0m\n' "$*"; } # ── Update checker (once per day, via version.txt) ─────────────────────────── _phpvm_check_update() { @@ -185,6 +189,17 @@ _phpvm_auto() { return 0 } +# ── Is phpvm sourced from a shell rc? ───────────────────────────────────────── +# True if any login rc already sources phpvm.sh, meaning `use` will persist +# across sessions and the "add to your rc" warning would just be noise. +_phpvm_in_rc() { + local f + for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile" "$HOME/.bash_profile"; do + [[ -f "$f" ]] && grep -Fq "$PHPVM_DIR/phpvm.sh" "$f" && return 0 + done + return 1 +} + # ── Get current version ─────────────────────────────────────────────────────── _phpvm_current_version() { if [[ -L "$PHPVM_CURRENT" ]]; then @@ -269,6 +284,37 @@ _phpvm_cpus() { nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2 } +# Resolve a partial version to the highest published patch on php.net. +# "8" -> latest 8.x (e.g. 8.5.7) +# "8.3" -> latest 8.3.x (e.g. 8.3.31) +# A full "x.y.z" passes through untouched. Echoes the resolved version on +# success; non-zero exit if nothing matched or the network was unreachable. +_phpvm_resolve_remote() { + local req="$1" + [[ "$req" =~ ^[0-9]+$ || "$req" =~ ^[0-9]+\.[0-9]+$ ]] || { echo "$req"; return 0; } + + local major="${req%%.*}" + local api="https://www.php.net/releases/index.php?json&max=100&version=$major" + local json + if command -v curl &>/dev/null; then + json=$(curl -fsSL --max-time 10 "$api" 2>/dev/null) + elif command -v wget &>/dev/null; then + json=$(wget -qO- --timeout=10 "$api" 2>/dev/null) + fi + [[ -z "$json" ]] && return 1 + + # Top-level JSON keys are "x.y.z" version strings; keep the ones whose + # prefix matches the request (the (\.|$) guard stops 8.3 matching 8.30.x). + local match + match=$(printf '%s\n' "$json" \ + | grep -oE '"[0-9]+\.[0-9]+\.[0-9]+"' \ + | tr -d '"' \ + | grep -E "^${req//./\\.}(\.|$)" \ + | sort -V | tail -1) + [[ -n "$match" ]] || return 1 + echo "$match" +} + # ============================================================================== # phpvm install # ============================================================================== @@ -276,6 +322,20 @@ phpvm_install() { local ver="$1" [[ -z "$ver" ]] && { _err "Usage: phpvm install (e.g. phpvm install 8.3.0)"; return 1; } + # Partial version: "8" -> latest 8.x, "8.3" -> latest 8.3.x. + if [[ "$ver" =~ ^[0-9]+$ || "$ver" =~ ^[0-9]+\.[0-9]+$ ]]; then + _step "Resolving latest patch for PHP $ver ..." + local resolved + if resolved=$(_phpvm_resolve_remote "$ver") && [[ -n "$resolved" ]]; then + _ok "Latest PHP $ver -> $resolved" + ver="$resolved" + else + _err "Could not resolve a release for PHP $ver." + _dim "Browse available versions: https://www.php.net/releases/" + return 1 + fi + fi + local target="$PHPVM_VERSIONS/$ver" if [[ -d "$target" ]]; then @@ -400,7 +460,9 @@ phpvm_install() { rm -rf "$src_dir" _ok "PHP $ver installed successfully." - _dim "Activate with: phpvm use $ver" + + # Activate the freshly built version right away (point 4 — auto-use). + phpvm_use "$ver" } # ============================================================================== @@ -427,8 +489,10 @@ phpvm_use() { _phpvm_use_path _ok "Now using PHP $ver" - php --version 2>/dev/null | head -1 - _warn "To persist across sessions, ensure your shell rc sources phpvm." + php --version 2>/dev/null | head -1 | sed 's/^/ /' + # Only nudge if phpvm isn't wired into a shell rc yet — otherwise this is + # already persistent and the warning is just noise on every `use`. + _phpvm_in_rc || _warn "To persist across sessions, add to your shell rc: source \"$PHPVM_DIR/phpvm.sh\"" } # ============================================================================== @@ -467,7 +531,7 @@ phpvm_current() { if [[ -n "$cur" ]]; then echo "" echo -e " \033[32mActive: $cur\033[0m" - php --version 2>/dev/null + php --version 2>/dev/null | sed 's/^/ /' echo "" else _warn "No PHP version active. Run: phpvm use " @@ -903,7 +967,7 @@ EOF _ok " phar : $phar" _ok " shim : $shim" echo "" - "$php_bin" "$phar" --version 2>/dev/null + "$php_bin" "$phar" --version 2>/dev/null | sed 's/^/ /' echo "" _dim "Note: composer is installed inside the PHP $cur bin dir." _dim "After 'phpvm use ', re-run 'phpvm composer' for that version." @@ -1136,6 +1200,52 @@ phpvm_hook() { esac } +# ============================================================================== +# DID-YOU-MEAN (unknown command handling) +# ============================================================================== +# Iterative Levenshtein distance between $1 and $2 (two-row, O(n) memory). +_phpvm_levenshtein() { + local a="$1" b="$2" + local la=${#a} lb=${#b} + (( la == 0 )) && { echo "$lb"; return; } + (( lb == 0 )) && { echo "$la"; return; } + local i j cost prev cur del ins sub min + local -a row + for (( j = 0; j <= lb; j++ )); do row[j]=$j; done + for (( i = 1; i <= la; i++ )); do + prev=${row[0]} + row[0]=$i + for (( j = 1; j <= lb; j++ )); do + cur=${row[j]} + if [[ "${a:i-1:1}" == "${b:j-1:1}" ]]; then cost=0; else cost=1; fi + del=$(( row[j] + 1 )); ins=$(( row[j-1] + 1 )); sub=$(( prev + cost )) + min=$del + (( ins < min )) && min=$ins + (( sub < min )) && min=$sub + row[j]=$min + prev=$cur + done + done + echo "${row[lb]}" +} + +# Canonical command list (includes aliases) for suggestions. +_PHPVM_COMMANDS="install use list ls current uninstall remove which ini deps ext composer fix-ini auto hook upgrade update version help" + +# Unknown command: suggest the nearest match instead of dumping the full help. +_phpvm_unknown() { + local cmd="$1" + local best="" bestd=99 c d + for c in $_PHPVM_COMMANDS; do + d=$(_phpvm_levenshtein "$cmd" "$c") + (( d < bestd )) && { bestd=$d; best=$c; } + done + _err "'$cmd' is not a phpvm command." + (( bestd <= 2 )) && _dim "Did you mean '$best'?" + _dim "Run 'phpvm help' to see all commands." + return 1 +} + # ============================================================================== # ENTRY POINT # ============================================================================== @@ -1163,7 +1273,7 @@ phpvm() { upgrade|update) phpvm_upgrade ;; version|-v) _ok "phpvm $PHPVM_VERSION" ;; help|--help) phpvm_help ;; - *) phpvm_help ;; + *) _phpvm_unknown "$cmd" ;; esac } diff --git a/linux/uninstall.sh b/linux/uninstall.sh new file mode 100644 index 0000000..52f57a1 --- /dev/null +++ b/linux/uninstall.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# ============================================================================== +# uninstall.sh — remove phpvm from Linux (reverses install.sh) +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/devhardiyanto/phpvm/main/linux/uninstall.sh | bash -s -- --yes +# bash uninstall.sh # interactive confirm, removes everything +# bash uninstall.sh --keep-versions # remove phpvm but keep built PHP versions +# bash uninstall.sh --yes # no prompt (for pipes / automation) +# +# Removes: ~/.phpvm and the "# phpvm" source block from your shell rc files. +# Does NOT touch anything else on your system. +# ============================================================================== + +set -e + +PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" + +_ok() { printf ' \033[32m%s\033[0m\n' "$*"; } +_step() { printf ' \033[36m> %s\033[0m\n' "$*"; } +_warn() { printf ' \033[33m[warn] %s\033[0m\n' "$*"; } +_err() { printf ' \033[31m[error] %s\033[0m\n' "$*" >&2; } +_dim() { printf ' \033[90m%s\033[0m\n' "$*"; } + +assume_yes=0 +keep_versions=0 +for arg in "$@"; do + case "$arg" in + -y|--yes) assume_yes=1 ;; + --keep-versions) keep_versions=1 ;; + -h|--help) + echo "" + echo " phpvm uninstaller" + echo " --yes, -y Skip the confirmation prompt" + echo " --keep-versions Remove phpvm but keep built PHP versions" + echo " --help, -h Show this help" + echo "" + exit 0 + ;; + *) _warn "Ignoring unknown option: $arg" ;; + esac +done + +echo "" +echo -e " \033[36mphpvm uninstaller\033[0m" +echo -e " \033[90m──────────────────────────────────────────────────────\033[0m" +echo "" + +if [[ ! -d "$PHPVM_DIR" ]]; then + _warn "phpvm not found at $PHPVM_DIR — nothing to remove." +fi + +# What's about to happen. +if [[ $keep_versions -eq 1 ]]; then + _dim "Will remove phpvm but KEEP built PHP versions in:" + _dim " $PHPVM_DIR/versions" +else + _dim "Will remove EVERYTHING under: $PHPVM_DIR" + _dim "(including all built PHP versions; pass --keep-versions to retain them)" +fi +_dim "Will strip the '# phpvm' source line from your shell rc files." +echo "" + +# Confirm unless --yes. When piped (curl | bash) stdin is the script, so read +# from the controlling terminal instead. +if [[ $assume_yes -eq 0 ]]; then + reply="" + if [[ -r /dev/tty ]]; then + printf " Proceed? [y/N] " > /dev/tty + read -r reply < /dev/tty + else + _err "No terminal to confirm on. Re-run with --yes to proceed non-interactively." + exit 1 + fi + case "$reply" in + [Yy]|[Yy][Ee][Ss]) ;; + *) echo " Aborted. Nothing was changed."; exit 0 ;; + esac +fi + +# 1. Remove the source block from shell rc files. +_phpvm_strip_rc() { + local rc_file="$1" + [[ -f "$rc_file" ]] || return 0 + grep -Fq "$PHPVM_DIR/phpvm.sh" "$rc_file" || return 0 + + local tmp + tmp=$(mktemp) || { _warn "mktemp failed; skipping $rc_file"; return 0; } + # Drop any line that sources our phpvm.sh, plus a "# phpvm" marker line + # immediately preceding such a source line. + awk -v marker="$PHPVM_DIR/phpvm.sh" ' + { lines[NR] = $0 } + END { + for (i = 1; i <= NR; i++) { + cur = lines[i] + nxt = (i < NR) ? lines[i + 1] : "" + if (cur == "# phpvm" && index(nxt, marker) > 0) continue + if (index(cur, marker) > 0) continue + print cur + } + } + ' "$rc_file" > "$tmp" + cat "$tmp" > "$rc_file" + rm -f "$tmp" + _ok "Cleaned $rc_file" +} + +_step "Cleaning shell rc files ..." +_phpvm_strip_rc "$HOME/.bashrc" +_phpvm_strip_rc "$HOME/.zshrc" +_phpvm_strip_rc "$HOME/.profile" +_phpvm_strip_rc "$HOME/.bash_profile" + +# 2. Remove the phpvm directory. +if [[ -d "$PHPVM_DIR" ]]; then + if [[ $keep_versions -eq 1 && -d "$PHPVM_DIR/versions" ]]; then + _step "Removing phpvm (keeping built versions) ..." + # Move versions aside, nuke the rest, move versions back. + find "$PHPVM_DIR" -mindepth 1 -maxdepth 1 ! -name versions -exec rm -rf {} + + _ok "Removed phpvm. Kept: $PHPVM_DIR/versions" + _dim "Delete it later with: rm -rf \"$PHPVM_DIR\"" + else + _step "Removing $PHPVM_DIR ..." + rm -rf "$PHPVM_DIR" + _ok "Removed $PHPVM_DIR" + fi +fi + +echo "" +_ok "phpvm uninstalled." +_dim "The 'phpvm' function lingers in this shell session — restart your" +_dim "terminal (or open a new one) to clear it completely." +echo "" diff --git a/tests/linux/commands.bats b/tests/linux/commands.bats index 1c4a160..6063f14 100644 --- a/tests/linux/commands.bats +++ b/tests/linux/commands.bats @@ -182,3 +182,55 @@ EOF [ "$status" -ne 0 ] [[ "$output" == *"No active PHP version"* ]] } + +# ---------- did-you-mean ---------- + +@test "levenshtein: known distances" { + [ "$(_phpvm_levenshtein kitten sitting)" -eq 3 ] + [ "$(_phpvm_levenshtein install install)" -eq 0 ] + [ "$(_phpvm_levenshtein intsall install)" -eq 2 ] + [ "$(_phpvm_levenshtein '' list)" -eq 4 ] +} + +@test "unknown: suggests nearest command for a close typo" { + run phpvm intsall + [ "$status" -ne 0 ] + [[ "$output" == *"is not a phpvm command"* ]] + [[ "$output" == *"Did you mean 'install'?"* ]] +} + +@test "unknown: no suggestion when nothing is close" { + run phpvm zzzzzz + [ "$status" -ne 0 ] + [[ "$output" == *"is not a phpvm command"* ]] + [[ "$output" != *"Did you mean"* ]] +} + +# ---------- partial version resolution ---------- + +@test "resolve: full x.y.z passes through without network" { + run _phpvm_resolve_remote 8.3.10 + [ "$status" -eq 0 ] + [ "$output" = "8.3.10" ] +} + +@test "resolve: major.minor picks highest patch (stubbed curl)" { + curl() { printf '%s' '{"8.3.31":{},"8.3.9":{},"8.4.22":{}}'; } + run _phpvm_resolve_remote 8.3 + [ "$status" -eq 0 ] + [ "$output" = "8.3.31" ] +} + +@test "resolve: bare major picks highest overall (stubbed curl)" { + curl() { printf '%s' '{"8.5.7":{},"8.4.22":{},"8.3.31":{}}'; } + run _phpvm_resolve_remote 8 + [ "$status" -eq 0 ] + [ "$output" = "8.5.7" ] +} + +@test "resolve: 8.3 does not match 8.30.x (stubbed curl)" { + curl() { printf '%s' '{"8.3.5":{},"8.30.1":{}}'; } + run _phpvm_resolve_remote 8.3 + [ "$status" -eq 0 ] + [ "$output" = "8.3.5" ] +} diff --git a/tests/windows/PureFunctions.Tests.ps1 b/tests/windows/PureFunctions.Tests.ps1 index b4e31e4..2e1cd0f 100644 --- a/tests/windows/PureFunctions.Tests.ps1 +++ b/tests/windows/PureFunctions.Tests.ps1 @@ -77,6 +77,27 @@ Describe 'Resolve-LatestPatch' { Resolve-LatestPatch '8.9' | Should -BeNullOrEmpty } + It 'Resolves a bare major to the highest overall patch' { + Mock -CommandName Get-WebString -MockWith { + @' +... +... +... +'@ + } + Resolve-LatestPatch '8' | Should -Be '8.5.7' + } + + It 'Does not let 8.3 match 8.30.x' { + Mock -CommandName Get-WebString -MockWith { + @' +... +... +'@ + } + Resolve-LatestPatch '8.3' | Should -Be '8.3.5' + } + It 'Matches uppercase VC15 used by PHP 7.x archives' { Mock -CommandName Get-WebString -MockWith { @' @@ -101,6 +122,44 @@ Describe 'Resolve-LatestPatch' { } } +Describe 'Get-Levenshtein' { + BeforeAll { + . $PSScriptRoot/Common.ps1 + } + + It 'Returns 0 for identical strings' { + Get-Levenshtein 'install' 'install' | Should -Be 0 + } + + It 'Counts single-edit typos' { + Get-Levenshtein 'intsall' 'install' | Should -Be 2 + Get-Levenshtein 'usee' 'use' | Should -Be 1 + } + + It 'Equals the other length when one string is empty' { + Get-Levenshtein '' 'list' | Should -Be 4 + Get-Levenshtein 'list' '' | Should -Be 4 + } +} + +Describe 'Invoke-Unknown' { + BeforeAll { + . $PSScriptRoot/Common.ps1 + } + + It 'Suggests the nearest command for a close typo' { + $out = Invoke-Unknown 'intsall' 6>&1 | Out-String + $out | Should -Match "is not a phpvm command" + $out | Should -Match "Did you mean 'install'\?" + } + + It 'Omits a suggestion when nothing is close' { + $out = Invoke-Unknown 'zzzzzz' 6>&1 | Out-String + $out | Should -Match "is not a phpvm command" + $out | Should -Not -Match "Did you mean" + } +} + Describe 'Get-PHPZipHash' { BeforeAll { . $PSScriptRoot/Common.ps1 diff --git a/version.txt b/version.txt index f8a696c..27f9cd3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.7.2 +1.8.0 diff --git a/windows/install.ps1 b/windows/install.ps1 index 09cfd23..78af709 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -7,7 +7,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$PHPVM_VERSION = "1.7.2" +$PHPVM_VERSION = "1.8.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $PHPVM_BIN = "$PHPVM_DIR\bin" diff --git a/windows/phpvm.ps1 b/windows/phpvm.ps1 index 7911268..f966343 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -15,7 +15,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.7.2" +$PHPVM_VERSION = "1.8.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current" @@ -164,12 +164,14 @@ function Resolve-PHPURL ([string]$ver) { return $null } -# "8.3" -> highest "8.3.x" published on windows.php.net. -function Resolve-LatestPatch ([string]$majorMinor) { - $patches = @() - $escaped = [regex]::Escape($majorMinor) - # (?i) - older archives use uppercase (VC11/VC14/VC15); newer 8.x lowercase (vs16/vs17). - $pattern = "(?i)php-($escaped\.\d+)-(?:nts-)?Win32-(?:vs1[567]|vc1[145])-x64\.zip" +# Resolve a partial version to the highest patch published on windows.php.net. +# "8" -> latest 8.x (e.g. 8.5.7) +# "8.3" -> latest 8.3.x (e.g. 8.3.31) +function Resolve-LatestPatch ([string]$request) { + $found = @() + # Capture the full x.y.z from any Win32 build name. + # (?i) - older archives use uppercase (VC11/VC14/VC15); newer lowercase (vs16/vs17). + $pattern = '(?i)php-(\d+\.\d+\.\d+)-(?:nts-)?Win32-(?:vs1[567]|vc1[145])-x64\.zip' foreach ($index in @( "https://windows.php.net/downloads/releases/" @@ -179,12 +181,17 @@ function Resolve-LatestPatch ([string]$majorMinor) { $html = Get-WebString $index 5 } catch { continue } foreach ($m in [regex]::Matches($html, $pattern)) { - $patches += $m.Groups[1].Value + $found += $m.Groups[1].Value } } - if (-not $patches) { return $null } - return ($patches | Sort-Object -Unique | Sort-Object { [version]$_ } -Descending | Select-Object -First 1) + if (-not $found) { return $null } + # Keep versions whose prefix matches the request. The '(\.|$)' guard stops + # "8.3" from matching "8.30.x" and "8" from matching "18.x". + $filter = '^' + [regex]::Escape($request) + '(\.|$)' + $cand = $found | Where-Object { $_ -match $filter } + if (-not $cand) { return $null } + return ($cand | Sort-Object -Unique | Sort-Object { [version]$_ } -Descending | Select-Object -First 1) } # -- Junction helpers ---------------------------------------------------------- @@ -284,8 +291,8 @@ function Test-URLExists ([string]$url) { function Invoke-Install ([string]$ver) { if (-not $ver) { Write-Err "Usage: phpvm install (e.g. phpvm install 8.3.0)"; return } - # Allow "8.3" -> resolve to latest patch - if ($ver -match '^\d+\.\d+$') { + # Allow "8" -> latest 8.x and "8.3" -> latest 8.3.x. + if ($ver -match '^\d+(\.\d+)?$') { Write-Step "Resolving latest patch for PHP $ver ..." $resolved = Resolve-LatestPatch $ver if (-not $resolved) { @@ -367,7 +374,9 @@ function Invoke-Install ([string]$ver) { } Write-Ok "PHP $ver installed successfully." - Write-Dim "Activate with: phpvm use $ver" + + # Activate the freshly installed version right away (point 4 - auto-use). + Invoke-Use $ver } function Invoke-Use ([string]$ver) { @@ -429,7 +438,7 @@ function Invoke-Current { Write-Host "" Write-Host " Active: $cur" -ForegroundColor Green try { - & "$CURRENT_LINK\php.exe" --version 2>$null + & "$CURRENT_LINK\php.exe" --version 2>$null | ForEach-Object { Write-Host " $_" } } catch { return } @@ -1170,7 +1179,7 @@ php "%~dp0composer.phar" %* Write-Ok " phar : $composerPhar" Write-Ok " shim : $composerBat" Write-Host "" - & $info.Exe "$phpRoot\composer.phar" --version + & $info.Exe "$phpRoot\composer.phar" --version | ForEach-Object { Write-Host " $_" } Write-Host "" Write-Dim "Note: composer.bat is inside the PHP version folder." Write-Dim "If you switch PHP version, run 'phpvm composer' again for that version." @@ -1299,6 +1308,44 @@ function Invoke-Upgrade { } +# -- Did-you-mean (unknown command handling) ----------------------------------- +# Iterative Levenshtein distance (two-row, O(n) memory). +function Get-Levenshtein ([string]$a, [string]$b) { + $la = $a.Length; $lb = $b.Length + if ($la -eq 0) { return $lb } + if ($lb -eq 0) { return $la } + $row = 0..$lb + for ($i = 1; $i -le $la; $i++) { + $prev = $row[0] + $row[0] = $i + for ($j = 1; $j -le $lb; $j++) { + $cur = $row[$j] + $cost = if ($a[$i - 1] -eq $b[$j - 1]) { 0 } else { 1 } + $del = $row[$j] + 1 + $ins = $row[$j - 1] + 1 + $sub = $prev + $cost + $row[$j] = [Math]::Min([Math]::Min($del, $ins), $sub) + $prev = $cur + } + } + return $row[$lb] +} + +# Unknown command: suggest the nearest match instead of dumping the full help. +function Invoke-Unknown ([string]$cmd) { + $cmds = @("install","use","list","ls","current","uninstall","remove", + "which","ini","fix-ini","ext","composer","auto","hook", + "upgrade","update","version","help") + $best = ""; $bestd = 99 + foreach ($c in $cmds) { + $d = Get-Levenshtein $cmd.ToLower() $c + if ($d -lt $bestd) { $bestd = $d; $best = $c } + } + Write-Err "'$cmd' is not a phpvm command." + if ($bestd -le 2) { Write-Dim "Did you mean '$best'?" } + Write-Dim "Run 'phpvm help' to see all commands." +} + # Tests dot-source this file and set $env:PHPVM_NO_ENTRY=1 to skip the entry point. if (-not $env:PHPVM_NO_ENTRY) { Initialize-PHPVM @@ -1324,6 +1371,7 @@ if (-not $env:PHPVM_NO_ENTRY) { { $_ -in "upgrade", "update" } { Invoke-Upgrade } { $_ -in "version", "-v" } { Write-Ok "phpvm $PHPVM_VERSION" } { $_ -in "help", "--help" } { Show-Help } - default { Show-Help } + "" { Show-Help } + default { Invoke-Unknown $Command } } } diff --git a/windows/uninstall.ps1 b/windows/uninstall.ps1 new file mode 100644 index 0000000..8c83c5a --- /dev/null +++ b/windows/uninstall.ps1 @@ -0,0 +1,113 @@ +# ============================================================================== +# uninstall.ps1 - remove phpvm from Windows (reverses install.ps1) +# Run as normal user (no admin required) +# +# Usage: +# .\uninstall.ps1 # interactive confirm, removes everything +# .\uninstall.ps1 -KeepVersions # remove phpvm but keep built PHP versions +# .\uninstall.ps1 -Yes # no prompt (for automation) +# +# Removes: %USERPROFILE%\.phpvm and the phpvm entry from your User PATH. +# Does NOT revert ExecutionPolicy (it is user-wide and may be wanted elsewhere). +# ============================================================================== + +param( + [switch]$KeepVersions, + [switch]$Yes, + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } +$PHPVM_BIN = "$PHPVM_DIR\bin" +$CURRENT_LINK = "$PHPVM_DIR\current" +$VERSIONS_DIR = "$PHPVM_DIR\versions" + +function Write-Ok ($m) { Write-Host " $m" -ForegroundColor Green } +function Write-Step ($m) { Write-Host " > $m" -ForegroundColor Cyan } +function Write-Warn ($m) { Write-Host " [warn] $m" -ForegroundColor Yellow } +function Write-Err ($m) { Write-Host " [error] $m" -ForegroundColor Red } +function Write-Dim ($m) { Write-Host " $m" -ForegroundColor DarkGray } + +if ($Help) { + Write-Host "" + Write-Host " phpvm uninstaller" + Write-Host " -Yes Skip the confirmation prompt" + Write-Host " -KeepVersions Remove phpvm but keep built PHP versions" + Write-Host " -Help Show this help" + Write-Host "" + return +} + +Write-Host "" +Write-Host " phpvm uninstaller" -ForegroundColor Cyan +Write-Host " ------------------------------------------------------" -ForegroundColor DarkGray +Write-Host "" + +if (-not (Test-Path $PHPVM_DIR)) { + Write-Warn "phpvm not found at $PHPVM_DIR - nothing to remove." +} + +if ($KeepVersions) { + Write-Dim "Will remove phpvm but KEEP built PHP versions in:" + Write-Dim " $VERSIONS_DIR" +} else { + Write-Dim "Will remove EVERYTHING under: $PHPVM_DIR" + Write-Dim "(including all built PHP versions; pass -KeepVersions to retain them)" +} +Write-Dim "Will remove the phpvm entry from your User PATH." +Write-Host "" + +# Confirm unless -Yes. +if (-not $Yes) { + $reply = Read-Host " Proceed? [y/N]" + if ($reply -notmatch '^(y|yes)$') { + Write-Host " Aborted. Nothing was changed." + return + } +} + +# 1. Remove phpvm paths from the User PATH (both the bin shim dir and current). +Write-Step "Cleaning User PATH ..." +$userPath = [Environment]::GetEnvironmentVariable("PATH", "User") +if ($null -eq $userPath) { $userPath = "" } +$parts = $userPath -split ";" | Where-Object { + $_ -and $_ -ne $PHPVM_BIN -and $_ -ne $CURRENT_LINK -and $_ -notlike "$PHPVM_DIR*" +} +$newPath = ($parts -join ";") -replace ";{2,}", ";" +if ($newPath -ne $userPath) { + [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") + Write-Ok "Removed phpvm from User PATH." +} else { + Write-Dim "No phpvm entry found in User PATH." +} + +# 2. Remove the phpvm directory. +if (Test-Path $PHPVM_DIR) { + if ($KeepVersions -and (Test-Path $VERSIONS_DIR)) { + Write-Step "Removing phpvm (keeping built versions) ..." + Get-ChildItem -Force $PHPVM_DIR | + Where-Object { $_.Name -ne "versions" } | + ForEach-Object { Remove-Item $_.FullName -Recurse -Force } + Write-Ok "Removed phpvm. Kept: $VERSIONS_DIR" + Write-Dim "Delete it later with: Remove-Item -Recurse -Force `"$PHPVM_DIR`"" + } else { + Write-Step "Removing $PHPVM_DIR ..." + # current is a junction; remove the reparse point before the tree. + if (Test-Path $CURRENT_LINK) { + $item = Get-Item $CURRENT_LINK -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + cmd /c rmdir "$CURRENT_LINK" | Out-Null + } + } + Remove-Item $PHPVM_DIR -Recurse -Force + Write-Ok "Removed $PHPVM_DIR" + } +} + +Write-Host "" +Write-Ok "phpvm uninstalled." +Write-Dim "Restart your terminal so the PATH change takes effect." +Write-Host "" From 042ababae5adfd712edf98d210cb0017a48b13be Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto Date: Thu, 18 Jun 2026 23:12:22 +0700 Subject: [PATCH 2/2] fix(ci): don't force CRLF on *.ps1 in .gitattributes Forcing `*.ps1 eol=crlf` made the Linux CI runner check PowerShell files out with CRLF, so the version-consistency grep captured a trailing `\r` (`1.8.0\r`) and failed. Pin only *.sh / *.bats to LF (the files that actually break with CRLF) and leave PowerShell to git's default handling. --- .gitattributes | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.gitattributes b/.gitattributes index 2dfdb17..a42d721 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,5 @@ # Shell scripts and bats tests must stay LF — CRLF breaks them on Linux/macOS -# (`bad interpreter: /usr/bin/env bash^M`). +# (`bad interpreter: /usr/bin/env bash^M`). Everything else keeps git's default +# handling (LF in the repo, autocrlf per developer) so CI greps see no CR. *.sh text eol=lf *.bats text eol=lf - -# PowerShell is fine with CRLF on Windows; keep it consistent there. -*.ps1 text eol=crlf -*.psd1 text eol=crlf -*.cmd text eol=crlf