diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0506be7..54a27f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,13 @@ jobs: sudo apt-get update sudo apt-get install -y shellcheck bats + - name: Verify phpvm.sh matches linux/src (drift check) + run: bash ./build.sh --check + - name: ShellCheck + # Deliberately only the generated file: linux/src/*.sh are fragments, + # not standalone scripts. The drift check above guarantees the generated + # file is what those modules say. run: shellcheck linux/install.sh linux/phpvm.sh - name: Bats (offline) diff --git a/README.md b/README.md index e2fe689..b6d36bd 100644 --- a/README.md +++ b/README.md @@ -395,22 +395,28 @@ TS/NTS + toolchain and downloads matching extension DLLs. ## Development -`windows/phpvm.ps1` is **generated** — do not edit it directly. The Windows -sources live in `windows/src/*.ps1`, one file per domain, and are concatenated -back into the single shipped script: +`windows/phpvm.ps1` and `linux/phpvm.sh` are **generated** — do not edit them +directly. The sources live in `windows/src/*.ps1` and `linux/src/*.sh`, one file +per domain, and are concatenated back into the single shipped script per OS: ```powershell pwsh ./build.ps1 # rebuild windows/phpvm.ps1 from windows/src/ pwsh ./build.ps1 -Check # fail if the two have drifted (what CI gates on) ``` -The numeric filename prefixes set the concat order and are load-bearing: -`00-header.ps1` opens with `param()`, which PowerShell requires to be the first -statement, and `99-entry.ps1` closes with the command dispatch, which has to see -every function already defined. Distribution is unaffected — the installer and -`phpvm upgrade` still fetch one file. +```bash +bash ./build.sh # rebuild linux/phpvm.sh from linux/src/ +bash ./build.sh --check # fail if the two have drifted (what CI gates on) +``` -`linux/phpvm.sh` is hand-written and not part of the build. +The numeric filename prefixes set the concat order. On Windows it is +load-bearing throughout: `00-header.ps1` opens with `param()`, which PowerShell +requires to be the first statement, and `99-entry.ps1` closes with the command +dispatch, which has to see every function already defined. On Linux only the +ends matter — bash resolves function bodies at call time, so `00-header.sh` +(constants) must come first and `99-entry.sh` (source-time init) last; the +modules in between are ordered for readability. Distribution is unaffected — the +installer and `phpvm upgrade` still fetch one file per OS. Tests: diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..f07d321 --- /dev/null +++ b/build.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ============================================================================== +# Concatenate linux/src/*.sh into the shipped linux/phpvm.sh. +# +# phpvm is distributed as a single file: linux/install.sh downloads +# linux/phpvm.sh straight from the repo and `phpvm upgrade` replaces it in +# place. Splitting the sources therefore has to collapse back into one file at +# build time rather than at load time. +# +# Modules concatenate in filename order, which is why they are numbered. In +# bash only two things about that order are load-bearing: 00-header.sh defines +# the constants everything else reads, and 99-entry.sh ends with the +# source-time init block, which calls functions that must already be defined. +# Function definition order in between is free — pick it for readability. +# +# Usage: +# bash ./build.sh # write linux/phpvm.sh +# bash ./build.sh --check # compare only; non-zero exit on drift (CI gate) +# ============================================================================== +set -euo pipefail + +root="$(cd "$(dirname "$0")" && pwd)" +src_dir="$root/linux/src" +out_file="$root/linux/phpvm.sh" + +check=0 +case "${1:-}" in + --check) check=1 ;; + "") ;; + *) echo "usage: build.sh [--check]" >&2; exit 2 ;; +esac + +# LC_ALL=C so the glob sorts the same everywhere; the repo stores LF only. +modules=$(LC_ALL=C ls "$src_dir"/*.sh 2>/dev/null || true) +[[ -n "$modules" ]] || { echo "No modules found in $src_dir" >&2; exit 1; } + +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT + +{ + cat <<'BANNER' +#!/usr/bin/env bash +# ============================================================================== +# GENERATED FILE - DO NOT EDIT +# Built from linux/src/*.sh (concatenated in filename order). +# Edit a module there, then run: bash ./build.sh +# CI fails the drift check if this file and the modules disagree. +# ============================================================================== +BANNER + + count=0 + while IFS= read -r m; do + name=$(basename "$m") + dashes=$(printf '%*s' $(( 74 - ${#name} > 1 ? 74 - ${#name} : 1 )) '' | tr ' ' '-') + printf '\n# --- src/%s %s\n' "$name" "$dashes" + # $(cat) drops trailing newlines - each module contributes exactly one + # trailing blank line, no matter how it was saved. + printf '%s\n' "$(cat "$m")" + count=$(( count + 1 )) + done <<< "$modules" +} > "$tmp" + +module_count=$(printf '%s\n' "$modules" | wc -l | tr -d ' ') + +if (( check )); then + [[ -f "$out_file" ]] || { echo "Missing $out_file - run: bash ./build.sh" >&2; exit 1; } + if diff -u "$out_file" "$tmp" > /dev/null; then + echo "OK: linux/phpvm.sh matches linux/src/*.sh ($module_count modules)." + exit 0 + fi + echo "DRIFT: linux/phpvm.sh does not match linux/src/*.sh." + echo "Rebuild with: bash ./build.sh" + diff -u "$out_file" "$tmp" | head -40 + exit 1 +fi + +cp "$tmp" "$out_file" +chmod +x "$out_file" +echo "Built linux/phpvm.sh from $module_count modules ($(wc -l < "$out_file" | tr -d ' ') lines)." diff --git a/linux/install.sh b/linux/install.sh index ce60831..a00d55b 100644 --- a/linux/install.sh +++ b/linux/install.sh @@ -6,7 +6,7 @@ set -e -PHPVM_VERSION="1.14.0" +PHPVM_VERSION="1.15.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 9dfe3e6..e98abf8 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -1,5 +1,13 @@ #!/usr/bin/env bash # ============================================================================== +# GENERATED FILE - DO NOT EDIT +# Built from linux/src/*.sh (concatenated in filename order). +# Edit a module there, then run: bash ./build.sh +# CI fails the drift check if this file and the modules disagree. +# ============================================================================== + +# --- src/00-header.sh -------------------------------------------------------------- +# ============================================================================== # phpvm — PHP Version Manager for Linux # Installs PHP from source (php.net). Manages per-version installs. # Repo: https://github.com/devhardiyanto/phpvm @@ -10,7 +18,7 @@ # phpvm use 8.3.0 # ============================================================================== -PHPVM_VERSION="1.14.0" +PHPVM_VERSION="1.15.0" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_VERSIONS="$PHPVM_DIR/versions" PHPVM_CURRENT="$PHPVM_DIR/current" @@ -21,6 +29,7 @@ PHPVM_UPDATE_URL="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/ver PHPVM_LAST_CHECK="$PHPVM_DIR/.last_update_check" PHPVM_CHECK_INTERVAL=3600 # 1 hour +# --- src/10-output.sh -------------------------------------------------------------- # ── Colors ──────────────────────────────────────────────────────────────────── # 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 @@ -101,118 +110,7 @@ _phpvm_ensure_bin_path() { esac } -# ── Auto-switch (.phpvmrc) ──────────────────────────────────────────────────── -# Walk from $1 (default $PWD) up to / looking for .phpvmrc. -# shellcheck disable=SC2120 # optional arg with PWD default - tests pass an arg. -_phpvm_find_rc() { - local dir="${1:-$PWD}" - while [[ -n "$dir" ]]; do - if [[ -f "$dir/.phpvmrc" ]]; then - echo "$dir/.phpvmrc" - return 0 - fi - [[ "$dir" == "/" ]] && return 1 - dir=$(dirname "$dir") - done - return 1 -} - -# First non-comment, non-empty line; strip inline #-comments and a leading `v`. -_phpvm_read_rc() { - local file="$1" - [[ -f "$file" ]] || return 1 - local line - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%%#*}" - line="${line#"${line%%[![:space:]]*}"}" - line="${line%"${line##*[![:space:]]}"}" - if [[ -n "$line" ]]; then - echo "${line#v}" - return 0 - fi - done < "$file" - return 1 -} - -# Map an rc version onto an installed version dir. Full semver passes through -# if installed; major.minor picks the highest installed patch. -_phpvm_resolve_rc() { - local requested="$1" - [[ -z "$requested" ]] && return 1 - if [[ -d "$PHPVM_VERSIONS/$requested/bin" ]]; then - echo "$requested" - return 0 - fi - if [[ "$requested" =~ ^[0-9]+\.[0-9]+$ ]]; then - local match - match=$(find "$PHPVM_VERSIONS" -mindepth 1 -maxdepth 1 -type d -name "${requested}.*" 2>/dev/null \ - | while read -r d; do [[ -x "$d/bin/php" ]] && basename "$d"; done \ - | sort -V | tail -1) - if [[ -n "$match" ]]; then - echo "$match" - return 0 - fi - fi - return 1 -} - -# Apply .phpvmrc to current shell. Session-only PATH change; tracked via -# $PHPVM_AUTO_ACTIVE so repeat calls no-op and leaving a project cleans up. -# Flags: -s / --silent for hook usage. -_phpvm_auto() { - local silent=0 - [[ "$1" == "-s" || "$1" == "--silent" ]] && silent=1 - - local rc resolved requested - if ! rc=$(_phpvm_find_rc); then - if [[ -n "${PHPVM_AUTO_ACTIVE:-}" ]]; then - local old="$PHPVM_VERSIONS/$PHPVM_AUTO_ACTIVE/bin" - PATH=$(echo "$PATH" | tr ':' '\n' | grep -vxF "$old" | paste -sd ':' -) - export PATH - unset PHPVM_AUTO_ACTIVE - [[ $silent -eq 0 ]] && _dim "Cleared auto PHP (no .phpvmrc upstream)." - fi - return 0 - fi - - if ! requested=$(_phpvm_read_rc "$rc"); then - [[ $silent -eq 0 ]] && _warn "$rc is empty or comment-only." - return 0 - fi - - if ! resolved=$(_phpvm_resolve_rc "$requested"); then - [[ $silent -eq 0 ]] && { - _warn "PHP $requested (from $rc) is not installed." - _dim "Run: phpvm install $requested" - } - return 0 - fi - - [[ "${PHPVM_AUTO_ACTIVE:-}" == "$resolved" ]] && return 0 - - if [[ -n "${PHPVM_AUTO_ACTIVE:-}" ]]; then - local old="$PHPVM_VERSIONS/$PHPVM_AUTO_ACTIVE/bin" - PATH=$(echo "$PATH" | tr ':' '\n' | grep -vxF "$old" | paste -sd ':' -) - fi - - local new="$PHPVM_VERSIONS/$resolved/bin" - export PATH="$new:$PATH" - export PHPVM_AUTO_ACTIVE="$resolved" - if [[ $silent -eq 0 ]]; then _ok "Auto-switched to PHP $resolved (from $rc)"; fi - 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 -} - +# --- src/20-os.sh ------------------------------------------------------------------ # ── Get current version ─────────────────────────────────────────────────────── _phpvm_current_version() { if [[ -L "$PHPVM_CURRENT" ]]; then @@ -234,6 +132,12 @@ _phpvm_detect_os() { fi } +# ── CPU count ───────────────────────────────────────────────────────────────── +_phpvm_cpus() { + nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2 +} + +# --- src/25-openssl.sh ------------------------------------------------------------- # Version of the OpenSSL that ./configure will actually resolve. pkg-config is # what configure consults, so ask it first; the `openssl` CLI is a fallback and # can disagree with the installed headers. Echoes "3.0.2"; non-zero if unknown. @@ -292,6 +196,8 @@ _phpvm_check_openssl_compat() { } # ── Check build dependencies ────────────────────────────────────────────────── + +# --- src/30-deps.sh ---------------------------------------------------------------- _phpvm_check_deps() { local missing=() local tools=("gcc" "make" "autoconf" "bison" "re2c" "pkg-config") @@ -357,11 +263,7 @@ _phpvm_print_dep_install() { echo "" } -# ── CPU count ───────────────────────────────────────────────────────────────── -_phpvm_cpus() { - nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2 -} - +# --- src/35-buildlog.sh ------------------------------------------------------------ # Run a long build step with its output appended to $PHPVM_LOG, showing a live # spinner + elapsed time so a multi-minute `make` never looks hung. # _phpvm_run_logged "Building with 8 cores" make -j8 @@ -431,6 +333,7 @@ _phpvm_show_build_error() { _dim " ${first}" } +# --- src/40-net.sh ----------------------------------------------------------------- # 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) @@ -540,6 +443,7 @@ _phpvm_verify_tarball() { return 0 } +# --- src/45-install.sh ------------------------------------------------------------- # ============================================================================== # phpvm install # ============================================================================== @@ -795,6 +699,7 @@ _phpvm_older_patch_hint() { _dim "Remove it with: phpvm uninstall $newest" } +# --- src/50-version.sh ------------------------------------------------------------- # ============================================================================== # phpvm use # ============================================================================== @@ -901,175 +806,186 @@ phpvm_which() { } # ============================================================================== -# phpvm doctor — read-only health check (never mutates state) +# phpvm ini # ============================================================================== -phpvm_doctor() { - echo "" - echo -e " \033[36mphpvm doctor — environment health check\033[0m" - echo -e " \033[36m─────────────────────────────────────────────────────────\033[0m" - - local ok=0 warn=0 - _dok() { printf ' \033[32m[ok] %s\033[0m\n' "$*"; ok=$((ok+1)); } - _dwarn(){ printf ' \033[33m[warn] %s\033[0m\n' "$*"; warn=$((warn+1)); } - - # 1. Active version + symlink health. +phpvm_ini() { local cur cur=$(_phpvm_current_version) - if [[ -n "$cur" ]]; then - _dok "Active PHP version: $cur" + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local ini="$PHPVM_VERSIONS/$cur/etc/php.ini" + if [[ -f "$ini" ]]; then + _step "Opening $ini" + "${EDITOR:-nano}" "$ini" else - _dwarn "No active PHP version. Run: phpvm use " + _err "php.ini not found: $ini" fi +} - # 2. PATH: whichever php resolves first is what runs - but a second PHP - # further down PATH still matters, because it is what comes back the - # moment phpvm's bin drops off (a distro upgrade rewriting the rc, a - # shell that never sourced phpvm.sh). `command -v` only ever reports the - # winner, so walk PATH ourselves. - # - # Split with parameter expansion rather than tr/awk: this is the check - # that tells you PATH is broken, so it must not itself depend on finding - # coreutils there. It also sidesteps zsh, where `for d in $PATH` does not - # split on colons at all. - local php_paths="" rest="$PATH" d - while [[ -n "$rest" ]]; do - d="${rest%%:*}" - if [[ "$d" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi - [[ -n "$d" && -x "$d/php" ]] || continue - case ":$php_paths:" in *":$d/php:"*) continue ;; esac # PATH may repeat - php_paths="${php_paths:+$php_paths:}$d/php" +# ============================================================================== +# phpvm deps — print dependency install command for current OS +# ============================================================================== +phpvm_deps() { + _phpvm_print_dep_install +} + +# --- src/55-auto.sh ---------------------------------------------------------------- +# ── Auto-switch (.phpvmrc) ──────────────────────────────────────────────────── +# Walk from $1 (default $PWD) up to / looking for .phpvmrc. +# shellcheck disable=SC2120 # optional arg with PWD default - tests pass an arg. +_phpvm_find_rc() { + local dir="${1:-$PWD}" + while [[ -n "$dir" ]]; do + if [[ -f "$dir/.phpvmrc" ]]; then + echo "$dir/.phpvmrc" + return 0 + fi + [[ "$dir" == "/" ]] && return 1 + dir=$(dirname "$dir") done + return 1 +} - if [[ -z "$php_paths" ]]; then - _dwarn "No 'php' on PATH. Run: phpvm use (and source phpvm.sh in your rc)." - else - local first="${php_paths%%:*}" - case "$first" in - "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) - _dok "'php' resolves to phpvm: $first" ;; - *) - _dwarn "'php' resolves to a non-phpvm install: $first" - _dim "Ensure $PHPVM_DIR is sourced in your shell rc, then open a new shell." ;; - esac +# First non-comment, non-empty line; strip inline #-comments and a leading `v`. +_phpvm_read_rc() { + local file="$1" + [[ -f "$file" ]] || return 1 + local line + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + if [[ -n "$line" ]]; then + echo "${line#v}" + return 0 + fi + done < "$file" + return 1 +} - local other="" p - rest="$php_paths" - while [[ -n "$rest" ]]; do - p="${rest%%:*}" - if [[ "$p" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi - case "$p" in - # `:` rather than an empty body - bash 3.2 on macOS is fussy - # about case arms, which is what broke the first cut of this. - "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) : ;; - *) other="$p"; break ;; - esac - done - if [[ -n "$other" && "$other" != "$first" ]]; then - _dwarn "Another PHP on PATH: $other" - _dim "It shadows phpvm whenever phpvm's bin is not first. Remove it or reorder PATH." +# Map an rc version onto an installed version dir. Full semver passes through +# if installed; major.minor picks the highest installed patch. +_phpvm_resolve_rc() { + local requested="$1" + [[ -z "$requested" ]] && return 1 + if [[ -d "$PHPVM_VERSIONS/$requested/bin" ]]; then + echo "$requested" + return 0 + fi + if [[ "$requested" =~ ^[0-9]+\.[0-9]+$ ]]; then + local match + match=$(find "$PHPVM_VERSIONS" -mindepth 1 -maxdepth 1 -type d -name "${requested}.*" 2>/dev/null \ + | while read -r d; do [[ -x "$d/bin/php" ]] && basename "$d"; done \ + | sort -V | tail -1) + if [[ -n "$match" ]]; then + echo "$match" + return 0 fi fi + return 1 +} - # 3. extension_dir must match what the active build was compiled with. - # Checking the directory merely exists passes an ini left pointing at a - # different version - the exact case fix-ini exists to repair. Go through - # the version's own binary, not PATH: check 2 may have just told us PATH - # resolves somewhere else entirely. - if [[ -n "$cur" ]]; then - local doc_php="$PHPVM_VERSIONS/$cur/bin/php" - if [[ ! -x "$doc_php" ]]; then - _dwarn "php binary missing for active version: $doc_php" - else - local ext_dir built_dir - ext_dir=$("$doc_php" -r "echo ini_get('extension_dir');" 2>/dev/null) - built_dir=$("$doc_php" -r "echo PHP_EXTENSION_DIR;" 2>/dev/null) - if [[ -z "$ext_dir" ]]; then - _dwarn "No extension_dir set in the active php.ini." - _dim "Fix with: phpvm fix-ini" - elif [[ "${ext_dir%/}" != "${built_dir%/}" ]]; then - _dwarn "extension_dir mismatch: '$ext_dir' != '$built_dir'" - _dim "Fix with: phpvm fix-ini" - elif [[ ! -d "$ext_dir" ]]; then - _dwarn "extension_dir does not exist: $ext_dir" - _dim "Fix with: phpvm fix-ini" - else - _dok "extension_dir matches active build." - fi +# Apply .phpvmrc to current shell. Session-only PATH change; tracked via +# $PHPVM_AUTO_ACTIVE so repeat calls no-op and leaving a project cleans up. +# Flags: -s / --silent for hook usage. +_phpvm_auto() { + local silent=0 + [[ "$1" == "-s" || "$1" == "--silent" ]] && silent=1 + + local rc resolved requested + if ! rc=$(_phpvm_find_rc); then + if [[ -n "${PHPVM_AUTO_ACTIVE:-}" ]]; then + local old="$PHPVM_VERSIONS/$PHPVM_AUTO_ACTIVE/bin" + PATH=$(echo "$PATH" | tr ':' '\n' | grep -vxF "$old" | paste -sd ':' -) + export PATH + unset PHPVM_AUTO_ACTIVE + [[ $silent -eq 0 ]] && _dim "Cleared auto PHP (no .phpvmrc upstream)." fi + return 0 fi - # 4. OpenSSL in PHP (HTTPS for composer / ext downloads). - if [[ -n "$cur" ]] && php -m 2>/dev/null | grep -qi '^openssl$'; then - _dok "openssl extension loaded (HTTPS OK)." - elif [[ -n "$cur" ]]; then - _dwarn "openssl not loaded — composer/HTTPS may fail." - _dim "Enable with: phpvm ext enable openssl" + if ! requested=$(_phpvm_read_rc "$rc"); then + [[ $silent -eq 0 ]] && _warn "$rc is empty or comment-only." + return 0 fi - # 5. Build toolchain (needed for future installs — build from source). - local missing=() - local t - for t in gcc make autoconf bison re2c pkg-config; do - command -v "$t" &>/dev/null || missing+=("$t") - done - if [[ ${#missing[@]} -eq 0 ]]; then - _dok "Build toolchain present (install/build ready)." - else - _dwarn "Missing build tools: ${missing[*]}" - _dim "See: phpvm deps" + if ! resolved=$(_phpvm_resolve_rc "$requested"); then + [[ $silent -eq 0 ]] && { + _warn "PHP $requested (from $rc) is not installed." + _dim "Run: phpvm install $requested" + } + return 0 fi - # 6. Host OpenSSL vs what is still buildable here. `install` already refuses - # PHP <8.1 on OpenSSL 3 (_phpvm_check_openssl_compat), but only once you - # have waited for a download - doctor should say it upfront. - local host_ssl - if host_ssl=$(_phpvm_openssl_version) && [[ -n "$host_ssl" ]]; then - local ssl_major="${host_ssl%%.*}" - if [[ "$ssl_major" =~ ^[0-9]+$ ]] && (( ssl_major >= 3 )); then - _dwarn "OpenSSL $host_ssl - PHP 8.0 and older cannot be built on this host." - _dim "Installed versions keep working; only new builds below 8.1 are refused." - else - _dok "OpenSSL $host_ssl (all supported PHP versions buildable)." - fi - else - _dim " OpenSSL version could not be determined - build compatibility unknown." - fi + [[ "${PHPVM_AUTO_ACTIVE:-}" == "$resolved" ]] && return 0 - echo "" - if [[ $warn -eq 0 ]]; then - _ok "All checks passed ($ok ok)." - else - _warn "$warn warning(s), $ok ok. See fixes above." + if [[ -n "${PHPVM_AUTO_ACTIVE:-}" ]]; then + local old="$PHPVM_VERSIONS/$PHPVM_AUTO_ACTIVE/bin" + PATH=$(echo "$PATH" | tr ':' '\n' | grep -vxF "$old" | paste -sd ':' -) fi - echo "" - unset -f _dok _dwarn 2>/dev/null + local new="$PHPVM_VERSIONS/$resolved/bin" + export PATH="$new:$PATH" + export PHPVM_AUTO_ACTIVE="$resolved" + if [[ $silent -eq 0 ]]; then _ok "Auto-switched to PHP $resolved (from $rc)"; fi + return 0 } -# ============================================================================== -# phpvm ini -# ============================================================================== -phpvm_ini() { - local cur - cur=$(_phpvm_current_version) - [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } - - local ini="$PHPVM_VERSIONS/$cur/etc/php.ini" - if [[ -f "$ini" ]]; then - _step "Opening $ini" - "${EDITOR:-nano}" "$ini" - else - _err "php.ini not found: $ini" - fi +# ── 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 } # ============================================================================== -# phpvm deps — print dependency install command for current OS +# AUTO-SWITCH COMMANDS (phpvm auto, phpvm hook) # ============================================================================== -phpvm_deps() { - _phpvm_print_dep_install +phpvm_auto() { + _phpvm_auto } +phpvm_hook() { + local sub="${1:-status}" + local flag="$PHPVM_DIR/.auto-hook" + case "$sub" in + enable) + mkdir -p "$PHPVM_DIR" + touch "$flag" + _ok "Hook enabled. Restart your shell, or run:" + _dim " source \"$PHPVM_DIR/phpvm.sh\"" + ;; + disable) + if [[ -f "$flag" ]]; then + rm -f "$flag" + _ok "Hook disabled. Restart your shell to fully unregister." + else + _warn "Hook is not enabled." + fi + ;; + status) + if [[ -f "$flag" ]]; then + _ok "Hook is enabled ($flag)" + else + _dim "Hook is disabled. Run: phpvm hook enable" + fi + ;; + *) + echo "" + echo " phpvm hook - manage the shell auto-switch hook" + echo " phpvm hook enable Enable .phpvmrc auto-switching on cd" + echo " phpvm hook disable Disable the hook" + echo " phpvm hook status Check whether the hook is enabled" + echo "" + ;; + esac +} + +# --- src/60-ext.sh ----------------------------------------------------------------- # ============================================================================== # EXT COMMANDS # ============================================================================== @@ -1416,6 +1332,7 @@ phpvm_ext() { esac } +# --- src/70-composer.sh ------------------------------------------------------------ # ============================================================================== # COMPOSER (one global composer that follows the active PHP version) # ============================================================================== @@ -1500,6 +1417,7 @@ EOF _dim "Composer follows your active PHP version — no need to re-run after 'phpvm use'." } +# --- src/72-wpcli.sh --------------------------------------------------------------- # ============================================================================== # WP-CLI (one global wp that follows the active PHP version) # ============================================================================== @@ -1567,6 +1485,153 @@ EOF _dim "WP-CLI follows your active PHP version — no need to re-run after 'phpvm use'." } +# --- src/80-maint.sh --------------------------------------------------------------- +# ============================================================================== +# phpvm doctor — read-only health check (never mutates state) +# ============================================================================== +phpvm_doctor() { + echo "" + echo -e " \033[36mphpvm doctor — environment health check\033[0m" + echo -e " \033[36m─────────────────────────────────────────────────────────\033[0m" + + local ok=0 warn=0 + _dok() { printf ' \033[32m[ok] %s\033[0m\n' "$*"; ok=$((ok+1)); } + _dwarn(){ printf ' \033[33m[warn] %s\033[0m\n' "$*"; warn=$((warn+1)); } + + # 1. Active version + symlink health. + local cur + cur=$(_phpvm_current_version) + if [[ -n "$cur" ]]; then + _dok "Active PHP version: $cur" + else + _dwarn "No active PHP version. Run: phpvm use " + fi + + # 2. PATH: whichever php resolves first is what runs - but a second PHP + # further down PATH still matters, because it is what comes back the + # moment phpvm's bin drops off (a distro upgrade rewriting the rc, a + # shell that never sourced phpvm.sh). `command -v` only ever reports the + # winner, so walk PATH ourselves. + # + # Split with parameter expansion rather than tr/awk: this is the check + # that tells you PATH is broken, so it must not itself depend on finding + # coreutils there. It also sidesteps zsh, where `for d in $PATH` does not + # split on colons at all. + local php_paths="" rest="$PATH" d + while [[ -n "$rest" ]]; do + d="${rest%%:*}" + if [[ "$d" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi + [[ -n "$d" && -x "$d/php" ]] || continue + case ":$php_paths:" in *":$d/php:"*) continue ;; esac # PATH may repeat + php_paths="${php_paths:+$php_paths:}$d/php" + done + + if [[ -z "$php_paths" ]]; then + _dwarn "No 'php' on PATH. Run: phpvm use (and source phpvm.sh in your rc)." + else + local first="${php_paths%%:*}" + case "$first" in + "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) + _dok "'php' resolves to phpvm: $first" ;; + *) + _dwarn "'php' resolves to a non-phpvm install: $first" + _dim "Ensure $PHPVM_DIR is sourced in your shell rc, then open a new shell." ;; + esac + + local other="" p + rest="$php_paths" + while [[ -n "$rest" ]]; do + p="${rest%%:*}" + if [[ "$p" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi + case "$p" in + # `:` rather than an empty body - bash 3.2 on macOS is fussy + # about case arms, which is what broke the first cut of this. + "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) : ;; + *) other="$p"; break ;; + esac + done + if [[ -n "$other" && "$other" != "$first" ]]; then + _dwarn "Another PHP on PATH: $other" + _dim "It shadows phpvm whenever phpvm's bin is not first. Remove it or reorder PATH." + fi + fi + + # 3. extension_dir must match what the active build was compiled with. + # Checking the directory merely exists passes an ini left pointing at a + # different version - the exact case fix-ini exists to repair. Go through + # the version's own binary, not PATH: check 2 may have just told us PATH + # resolves somewhere else entirely. + if [[ -n "$cur" ]]; then + local doc_php="$PHPVM_VERSIONS/$cur/bin/php" + if [[ ! -x "$doc_php" ]]; then + _dwarn "php binary missing for active version: $doc_php" + else + local ext_dir built_dir + ext_dir=$("$doc_php" -r "echo ini_get('extension_dir');" 2>/dev/null) + built_dir=$("$doc_php" -r "echo PHP_EXTENSION_DIR;" 2>/dev/null) + if [[ -z "$ext_dir" ]]; then + _dwarn "No extension_dir set in the active php.ini." + _dim "Fix with: phpvm fix-ini" + elif [[ "${ext_dir%/}" != "${built_dir%/}" ]]; then + _dwarn "extension_dir mismatch: '$ext_dir' != '$built_dir'" + _dim "Fix with: phpvm fix-ini" + elif [[ ! -d "$ext_dir" ]]; then + _dwarn "extension_dir does not exist: $ext_dir" + _dim "Fix with: phpvm fix-ini" + else + _dok "extension_dir matches active build." + fi + fi + fi + + # 4. OpenSSL in PHP (HTTPS for composer / ext downloads). + if [[ -n "$cur" ]] && php -m 2>/dev/null | grep -qi '^openssl$'; then + _dok "openssl extension loaded (HTTPS OK)." + elif [[ -n "$cur" ]]; then + _dwarn "openssl not loaded — composer/HTTPS may fail." + _dim "Enable with: phpvm ext enable openssl" + fi + + # 5. Build toolchain (needed for future installs — build from source). + local missing=() + local t + for t in gcc make autoconf bison re2c pkg-config; do + command -v "$t" &>/dev/null || missing+=("$t") + done + if [[ ${#missing[@]} -eq 0 ]]; then + _dok "Build toolchain present (install/build ready)." + else + _dwarn "Missing build tools: ${missing[*]}" + _dim "See: phpvm deps" + fi + + # 6. Host OpenSSL vs what is still buildable here. `install` already refuses + # PHP <8.1 on OpenSSL 3 (_phpvm_check_openssl_compat), but only once you + # have waited for a download - doctor should say it upfront. + local host_ssl + if host_ssl=$(_phpvm_openssl_version) && [[ -n "$host_ssl" ]]; then + local ssl_major="${host_ssl%%.*}" + if [[ "$ssl_major" =~ ^[0-9]+$ ]] && (( ssl_major >= 3 )); then + _dwarn "OpenSSL $host_ssl - PHP 8.0 and older cannot be built on this host." + _dim "Installed versions keep working; only new builds below 8.1 are refused." + else + _dok "OpenSSL $host_ssl (all supported PHP versions buildable)." + fi + else + _dim " OpenSSL version could not be determined - build compatibility unknown." + fi + + echo "" + if [[ $warn -eq 0 ]]; then + _ok "All checks passed ($ok ok)." + else + _warn "$warn warning(s), $ok ok. See fixes above." + fi + echo "" + + unset -f _dok _dwarn 2>/dev/null +} + # ============================================================================== # FIX-INI (sync extension_dir in active php.ini) # ============================================================================== @@ -1601,6 +1666,70 @@ phpvm_fix_ini() { _dim "Verify: phpvm ext list" } +# ============================================================================== +# SELF UPDATE +# ============================================================================== +phpvm_upgrade() { + local script_url="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/linux/phpvm.sh" + local version_url="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/version.txt" + local script_dest="$PHPVM_DIR/phpvm.sh" + local backup="$PHPVM_DIR/phpvm.sh.bak" + + _step "Checking latest version ..." + + local latest + if command -v curl &>/dev/null; then + latest=$(curl -fsSL --max-time 5 "$version_url" 2>/dev/null | tr -d '[:space:]') + elif command -v wget &>/dev/null; then + latest=$(wget -qO- --timeout=5 "$version_url" 2>/dev/null | tr -d '[:space:]') + else + _err "curl or wget is required." + return 1 + fi + + if [[ -z "$latest" ]]; then + _err "Could not reach GitHub. Check your connection." + return 1 + fi + + # Compare versions + local newer + newer=$(printf '%s\n%s' "$PHPVM_VERSION" "$latest" | sort -V | tail -1) + if [[ "$newer" == "$PHPVM_VERSION" && "$latest" == "$PHPVM_VERSION" ]]; then + _ok "Already up to date. (phpvm $PHPVM_VERSION)" + return 0 + fi + + _step "Upgrading phpvm $PHPVM_VERSION → $latest ..." + + # Backup + cp "$script_dest" "$backup" + _dim "Backup saved: $backup" + + # Download new version + local tmp="$PHPVM_DIR/phpvm.sh.tmp" + if command -v curl &>/dev/null; then + curl -fsSL "$script_url" -o "$tmp" || { _err "Download failed."; return 1; } + else + wget -qO "$tmp" "$script_url" || { _err "Download failed."; return 1; } + fi + + # Verify it looks like a valid phpvm script + if ! grep -q "PHPVM_VERSION" "$tmp"; then + _err "Downloaded file seems invalid. Rolling back." + rm -f "$tmp" + return 1 + fi + + mv "$tmp" "$script_dest" + chmod +x "$script_dest" + + _ok "phpvm upgraded to $latest!" + _dim "Run: source ~/.bashrc (or restart terminal)" + _dim "Backup of old version: $backup" +} + +# --- src/90-help.sh ---------------------------------------------------------------- # ============================================================================== # HELP # ============================================================================== @@ -1693,112 +1822,6 @@ phpvm_help() { EOF } -# ============================================================================== -# SELF UPDATE -# ============================================================================== -phpvm_upgrade() { - local script_url="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/linux/phpvm.sh" - local version_url="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/version.txt" - local script_dest="$PHPVM_DIR/phpvm.sh" - local backup="$PHPVM_DIR/phpvm.sh.bak" - - _step "Checking latest version ..." - - local latest - if command -v curl &>/dev/null; then - latest=$(curl -fsSL --max-time 5 "$version_url" 2>/dev/null | tr -d '[:space:]') - elif command -v wget &>/dev/null; then - latest=$(wget -qO- --timeout=5 "$version_url" 2>/dev/null | tr -d '[:space:]') - else - _err "curl or wget is required." - return 1 - fi - - if [[ -z "$latest" ]]; then - _err "Could not reach GitHub. Check your connection." - return 1 - fi - - # Compare versions - local newer - newer=$(printf '%s\n%s' "$PHPVM_VERSION" "$latest" | sort -V | tail -1) - if [[ "$newer" == "$PHPVM_VERSION" && "$latest" == "$PHPVM_VERSION" ]]; then - _ok "Already up to date. (phpvm $PHPVM_VERSION)" - return 0 - fi - - _step "Upgrading phpvm $PHPVM_VERSION → $latest ..." - - # Backup - cp "$script_dest" "$backup" - _dim "Backup saved: $backup" - - # Download new version - local tmp="$PHPVM_DIR/phpvm.sh.tmp" - if command -v curl &>/dev/null; then - curl -fsSL "$script_url" -o "$tmp" || { _err "Download failed."; return 1; } - else - wget -qO "$tmp" "$script_url" || { _err "Download failed."; return 1; } - fi - - # Verify it looks like a valid phpvm script - if ! grep -q "PHPVM_VERSION" "$tmp"; then - _err "Downloaded file seems invalid. Rolling back." - rm -f "$tmp" - return 1 - fi - - mv "$tmp" "$script_dest" - chmod +x "$script_dest" - - _ok "phpvm upgraded to $latest!" - _dim "Run: source ~/.bashrc (or restart terminal)" - _dim "Backup of old version: $backup" -} - -# ============================================================================== -# AUTO-SWITCH COMMANDS (phpvm auto, phpvm hook) -# ============================================================================== -phpvm_auto() { - _phpvm_auto -} - -phpvm_hook() { - local sub="${1:-status}" - local flag="$PHPVM_DIR/.auto-hook" - case "$sub" in - enable) - mkdir -p "$PHPVM_DIR" - touch "$flag" - _ok "Hook enabled. Restart your shell, or run:" - _dim " source \"$PHPVM_DIR/phpvm.sh\"" - ;; - disable) - if [[ -f "$flag" ]]; then - rm -f "$flag" - _ok "Hook disabled. Restart your shell to fully unregister." - else - _warn "Hook is not enabled." - fi - ;; - status) - if [[ -f "$flag" ]]; then - _ok "Hook is enabled ($flag)" - else - _dim "Hook is disabled. Run: phpvm hook enable" - fi - ;; - *) - echo "" - echo " phpvm hook - manage the shell auto-switch hook" - echo " phpvm hook enable Enable .phpvmrc auto-switching on cd" - echo " phpvm hook disable Disable the hook" - echo " phpvm hook status Check whether the hook is enabled" - echo "" - ;; - esac -} - # ============================================================================== # DID-YOU-MEAN (unknown command handling) # ============================================================================== @@ -1855,6 +1878,7 @@ _phpvm_unknown() { return 1 } +# --- src/99-entry.sh --------------------------------------------------------------- # ============================================================================== # ENTRY POINT # ============================================================================== diff --git a/linux/src/00-header.sh b/linux/src/00-header.sh new file mode 100644 index 0000000..f527eb3 --- /dev/null +++ b/linux/src/00-header.sh @@ -0,0 +1,21 @@ +# ============================================================================== +# phpvm — PHP Version Manager for Linux +# Installs PHP from source (php.net). Manages per-version installs. +# Repo: https://github.com/devhardiyanto/phpvm +# +# Usage: +# source ~/.phpvm/phpvm.sh (add to ~/.bashrc or ~/.zshrc) +# phpvm install 8.3.0 +# phpvm use 8.3.0 +# ============================================================================== + +PHPVM_VERSION="1.15.0" +PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" +PHPVM_VERSIONS="$PHPVM_DIR/versions" +PHPVM_CURRENT="$PHPVM_DIR/current" +PHPVM_BIN="$PHPVM_DIR/bin" # global shims (composer) — always on PATH +PHPVM_CACHE="$PHPVM_DIR/cache" +PHPVM_LOG="$PHPVM_DIR/build.log" +PHPVM_UPDATE_URL="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/version.txt" +PHPVM_LAST_CHECK="$PHPVM_DIR/.last_update_check" +PHPVM_CHECK_INTERVAL=3600 # 1 hour diff --git a/linux/src/10-output.sh b/linux/src/10-output.sh new file mode 100644 index 0000000..d470e39 --- /dev/null +++ b/linux/src/10-output.sh @@ -0,0 +1,79 @@ +# ── Colors ──────────────────────────────────────────────────────────────────── +# 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 (hourly, via version.txt) ───────────────────────────────── +_phpvm_check_update() { + # Skip in CI or if explicitly disabled + [[ -n "${CI:-}" || -n "${PHPVM_NO_UPDATE_CHECK:-}" ]] && return + + # Only check once per interval + if [[ -f "$PHPVM_LAST_CHECK" ]]; then + local last_ts now elapsed + # GNU stat (-c) on Linux, BSD stat (-f) on macOS. + last_ts=$(stat -c %Y "$PHPVM_LAST_CHECK" 2>/dev/null || \ + stat -f %m "$PHPVM_LAST_CHECK" 2>/dev/null || echo 0) + now=$(date +%s) + elapsed=$(( now - last_ts )) + [[ $elapsed -lt $PHPVM_CHECK_INTERVAL ]] && return + fi + + # Update timestamp first + touch "$PHPVM_LAST_CHECK" 2>/dev/null || return + + # Fetch latest version (3s timeout, silent) + local latest + if command -v curl &>/dev/null; then + latest=$(curl -fsSL --max-time 3 "$PHPVM_UPDATE_URL" 2>/dev/null | tr -d '[:space:]') + elif command -v wget &>/dev/null; then + latest=$(wget -qO- --timeout=3 "$PHPVM_UPDATE_URL" 2>/dev/null | tr -d '[:space:]') + else + return + fi + + [[ -z "$latest" ]] && return + + # Compare versions (sort -V = version sort) + local newer + newer=$(printf '%s\n%s' "$PHPVM_VERSION" "$latest" | sort -V | tail -1) + if [[ "$newer" == "$latest" && "$latest" != "$PHPVM_VERSION" ]]; then + echo "" + echo -e " \033[33m┌─────────────────────────────────────────────────────┐\033[0m" + echo -e " \033[33m│ phpvm update available: $PHPVM_VERSION → $latest \033[0m" + echo -e " \033[33m│ Run: curl -fsSL .../linux/install.sh | bash │\033[0m" + echo -e " \033[33m└─────────────────────────────────────────────────────┘\033[0m" + echo "" + fi +} + + +_phpvm_init() { + mkdir -p "$PHPVM_VERSIONS" "$PHPVM_CACHE" "$PHPVM_BIN" +} + +# ── PATH management ─────────────────────────────────────────────────────────── +# Order: $PHPVM_BIN (global shims like composer) before $PHPVM_CURRENT/bin so a +# global composer always wins over any stale per-version shim; both ahead of the +# rest of PATH. `php`, `pecl`, etc. still resolve from the active version's bin. +_phpvm_use_path() { + local bin="$PHPVM_CURRENT/bin" + # Remove any existing phpvm path entries + PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "$PHPVM_DIR" | paste -sd ':' -) + export PATH="$PHPVM_BIN:$bin:$PATH" +} + +# Ensure $PHPVM_BIN is on PATH even when no version is active yet, so the global +# `composer` shim is reachable (and gives a clean error) regardless. +_phpvm_ensure_bin_path() { + case ":$PATH:" in + *":$PHPVM_BIN:"*) ;; + *) export PATH="$PHPVM_BIN:$PATH" ;; + esac +} diff --git a/linux/src/20-os.sh b/linux/src/20-os.sh new file mode 100644 index 0000000..b1f4188 --- /dev/null +++ b/linux/src/20-os.sh @@ -0,0 +1,25 @@ +# ── Get current version ─────────────────────────────────────────────────────── +_phpvm_current_version() { + if [[ -L "$PHPVM_CURRENT" ]]; then + basename "$(readlink "$PHPVM_CURRENT")" + else + echo "" + fi +} + +# ── Detect OS / package manager ─────────────────────────────────────────────── +_phpvm_detect_os() { + if command -v apt-get &>/dev/null; then echo "apt" + elif command -v dnf &>/dev/null; then echo "dnf" + elif command -v yum &>/dev/null; then echo "yum" + elif command -v pacman &>/dev/null; then echo "pacman" + elif command -v zypper &>/dev/null; then echo "zypper" + elif command -v brew &>/dev/null; then echo "brew" + else echo "unknown" + fi +} + +# ── CPU count ───────────────────────────────────────────────────────────────── +_phpvm_cpus() { + nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2 +} diff --git a/linux/src/25-openssl.sh b/linux/src/25-openssl.sh new file mode 100644 index 0000000..c626ad4 --- /dev/null +++ b/linux/src/25-openssl.sh @@ -0,0 +1,58 @@ +# Version of the OpenSSL that ./configure will actually resolve. pkg-config is +# what configure consults, so ask it first; the `openssl` CLI is a fallback and +# can disagree with the installed headers. Echoes "3.0.2"; non-zero if unknown. +_phpvm_openssl_version() { + # LibreSSL and BoringSSL answer `openssl version` and ship an openssl.pc of + # their own, but their 3.x still defines RSA_SSLV23_PADDING — the premise of + # the guard below. Their numbering says nothing about it, so report the host + # as unknown rather than block a build that would have succeeded. + local banner="" + command -v openssl &>/dev/null && banner=$(openssl version 2>/dev/null) + [[ -n "$banner" && "$banner" != OpenSSL* ]] && return 1 + + local v="" + command -v pkg-config &>/dev/null && v=$(pkg-config --modversion openssl 2>/dev/null) + [[ -z "$v" && -n "$banner" ]] && v=$(echo "$banner" | awk '{print $2}') + # Strip a letter suffix: OpenSSL 1.1.1w -> 1.1.1 + v="${v%%[a-zA-Z]*}" + [[ -n "$v" ]] || return 1 + echo "$v" +} + +# PHP's ext/openssl references RSA_SSLV23_PADDING, which OpenSSL removed in +# 3.0. php-src only stopped using it in 8.1, so anything older dies partway +# through `make` with a wall of C errors. That is knowable up front, so refuse +# before spending ten minutes compiling. +# https://github.com/php/php-src/issues/9503 +# Escape hatch: PHPVM_SKIP_OPENSSL_CHECK=1 for setups where pkg-config resolves +# an OpenSSL 1.1 that this probe can't see. +_phpvm_check_openssl_compat() { + local ver="$1" + [[ -n "${PHPVM_SKIP_OPENSSL_CHECK:-}" ]] && return 0 + + local major="${ver%%.*}" minor + minor=$(echo "$ver" | cut -d. -f2) + # PHP 8.1+ builds fine against OpenSSL 3. + (( major > 8 )) && return 0 + (( major == 8 && minor >= 1 )) && return 0 + + local ssl ssl_major + ssl=$(_phpvm_openssl_version) || return 0 # can't tell -> don't block + ssl_major="${ssl%%.*}" + # A non-numeric major is something this probe doesn't model; `-lt` would + # error and fall through to blocking, so fail open here too. + [[ "$ssl_major" =~ ^[0-9]+$ ]] || return 0 + (( ssl_major < 3 )) && return 0 + + _err "PHP $ver cannot be built against OpenSSL $ssl." + _dim "OpenSSL 3.0 removed RSA_SSLV23_PADDING; PHP's openssl extension only" + _dim "stopped using it in 8.1, so the build would fail in ext/openssl." + echo "" + _dim "Options:" + _dim " - Install a supported line: phpvm install 8.1 (or newer)" + _dim " - Keep OpenSSL 1.1 as what pkg-config resolves, then re-run with:" + _dim " PHPVM_SKIP_OPENSSL_CHECK=1 phpvm install $ver" + return 1 +} + +# ── Check build dependencies ────────────────────────────────────────────────── diff --git a/linux/src/30-deps.sh b/linux/src/30-deps.sh new file mode 100644 index 0000000..bc0b1f0 --- /dev/null +++ b/linux/src/30-deps.sh @@ -0,0 +1,64 @@ +_phpvm_check_deps() { + local missing=() + local tools=("gcc" "make" "autoconf" "bison" "re2c" "pkg-config") + for tool in "${tools[@]}"; do + command -v "$tool" &>/dev/null || missing+=("$tool") + done + + if [[ ${#missing[@]} -gt 0 ]]; then + _warn "Missing build tools: ${missing[*]}" + _phpvm_print_dep_install + return 1 + fi + return 0 +} + +_phpvm_print_dep_install() { + local pm + pm=$(_phpvm_detect_os) + echo "" + _dim "Install build dependencies with:" + case "$pm" in + apt) + _dim " sudo apt-get install -y \\" + _dim " build-essential autoconf bison re2c pkg-config \\" + _dim " libxml2-dev libsqlite3-dev libssl-dev libcurl4-openssl-dev \\" + _dim " libonig-dev libzip-dev zlib1g-dev libreadline-dev \\" + _dim " libpng-dev libjpeg-dev libwebp-dev libfreetype6-dev \\" + _dim " libgmp-dev libsodium-dev libmysqlclient-dev libpq-dev" + ;; + dnf|yum) + _dim " sudo $pm install -y \\" + _dim " gcc make autoconf bison re2c pkg-config \\" + _dim " libxml2-devel sqlite-devel openssl-devel libcurl-devel \\" + _dim " oniguruma-devel libzip-devel zlib-devel readline-devel \\" + _dim " libpng-devel libjpeg-devel libwebp-devel freetype-devel \\" + _dim " gmp-devel libsodium-devel mysql-devel postgresql-devel" + ;; + pacman) + _dim " sudo pacman -S --needed \\" + _dim " base-devel autoconf bison re2c pkg-config \\" + _dim " libxml2 sqlite openssl curl oniguruma libzip \\" + _dim " libpng libjpeg libwebp freetype2 gmp libsodium mysql-libs postgresql-libs" + ;; + zypper) + _dim " sudo zypper install -y \\" + _dim " gcc make autoconf bison re2c pkg-config \\" + _dim " libxml2-devel sqlite3-devel libopenssl-devel libcurl-devel \\" + _dim " oniguruma-devel libzip-devel zlib-devel readline-devel \\" + _dim " libsodium-devel" + ;; + brew) + _dim " brew install \\" + _dim " autoconf bison re2c pkg-config \\" + _dim " openssl@3 libxml2 sqlite curl oniguruma libzip zlib readline \\" + _dim " libpng jpeg webp freetype gmp libsodium gettext" + _dim " (Xcode Command Line Tools required: xcode-select --install)" + ;; + *) + _dim " Please install: gcc make autoconf bison re2c pkg-config" + _dim " and dev libraries: libxml2, sqlite3, openssl, curl, oniguruma, libzip" + ;; + esac + echo "" +} diff --git a/linux/src/35-buildlog.sh b/linux/src/35-buildlog.sh new file mode 100644 index 0000000..b53209c --- /dev/null +++ b/linux/src/35-buildlog.sh @@ -0,0 +1,68 @@ +# Run a long build step with its output appended to $PHPVM_LOG, showing a live +# spinner + elapsed time so a multi-minute `make` never looks hung. +# _phpvm_run_logged "Building with 8 cores" make -j8 +# The spinner is drawn on stderr and only when stderr is a terminal, so CI and +# the bats suite see exactly the output they saw before. Returns the real exit +# code of the command. +_phpvm_run_logged() { + local label="$1"; shift + + if [[ ! -t 2 ]]; then + _step "$label ..." + "$@" >> "$PHPVM_LOG" 2>&1 + 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; [[ $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. + local frames="|/-\\" + local i=0 start=$SECONDS elapsed frame + while kill -0 "$pid" 2>/dev/null; do + elapsed=$((SECONDS - start)) + frame=${frames:$i:1} + i=$(( (i + 1) % 4 )) + printf '\r\033[36m %s %s ... %dm%02ds\033[0m' \ + "$frame" "$label" $((elapsed / 60)) $((elapsed % 60)) >&2 + sleep 0.5 + done + + wait "$pid" + local rc=$? + trap - INT + [[ $had_monitor -eq 1 ]] && set -m + printf '\r\033[K' >&2 + + elapsed=$((SECONDS - start)) + if [[ $rc -eq 0 ]]; then + _step "$label ... done in $((elapsed / 60))m$((elapsed % 60))s" + fi + return $rc +} + +# A failed build leaves thousands of lines of compiler noise in $PHPVM_LOG, and +# the line that explains it is buried near the middle — "See log" alone makes +# the user page through it. Pull out the first hard error instead. +_phpvm_show_build_error() { + [[ -f "$PHPVM_LOG" ]] || return 0 + + local first + # configure's own failure is the more useful line when both are present. + first=$(grep -m1 -E '^configure: error:' "$PHPVM_LOG" 2>/dev/null) + [[ -z "$first" ]] && first=$(grep -m1 -E '(^|[[:space:]])(fatal )?error:' "$PHPVM_LOG" 2>/dev/null) + [[ -z "$first" ]] && return 0 + + _err "First error in the log:" + _dim " ${first}" +} diff --git a/linux/src/40-net.sh b/linux/src/40-net.sh new file mode 100644 index 0000000..4ad4641 --- /dev/null +++ b/linux/src/40-net.sh @@ -0,0 +1,108 @@ +# 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" +} + +# Expected SHA-256 for php-.tar.gz, straight from php.net's release JSON. +# The per-version endpoint returns a "source" array whose entries each carry a +# filename and its sha256; splitting on "{" puts one entry per line so the digest +# next to the .tar.gz filename is the one we pick (never the .xz/.bz2 sibling). +_phpvm_php_sha256() { + local ver="$1" + local api="https://www.php.net/releases/index.php?json&version=$ver" + 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 + + local sum + sum=$(printf '%s' "$json" \ + | tr '{' '\n' \ + | grep -F "\"php-$ver.tar.gz\"" \ + | grep -oE '"sha256"[[:space:]]*:[[:space:]]*"[0-9a-f]{64}"' \ + | grep -oE '[0-9a-f]{64}' \ + | head -1) + [[ -n "$sum" ]] || return 1 + echo "$sum" +} + +# Digest a file with whatever the host has. PHP itself is not an option here the +# way it is for the composer/wp-cli phars - this runs *before* any PHP exists. +_phpvm_sha256_file() { + local file="$1" + if command -v sha256sum &>/dev/null; then + sha256sum "$file" 2>/dev/null | awk '{print $1}' + elif command -v shasum &>/dev/null; then + shasum -a 256 "$file" 2>/dev/null | awk '{print $1}' + elif command -v openssl &>/dev/null; then + openssl dgst -sha256 "$file" 2>/dev/null | awk '{print $NF}' + else + return 1 + fi +} + +# Verify a downloaded (or cached) tarball. Mismatch is fatal and takes the file +# with it; an unavailable digest or a host with no hashing tool degrades to a +# warning, matching the Windows fallback so an offline mirror or an EOL release +# that php.net no longer lists cannot brick an otherwise valid install. +_phpvm_verify_tarball() { + local file="$1" ver="$2" + + if [[ -n "${PHPVM_SKIP_HASH:-}" ]]; then + _dim "Skipping SHA-256 verification (PHPVM_SKIP_HASH is set)." + return 0 + fi + + local expected + if ! expected=$(_phpvm_php_sha256 "$ver") || [[ -z "$expected" ]]; then + _warn "No published SHA-256 for PHP $ver - skipping verification." + return 0 + fi + + local actual + if ! actual=$(_phpvm_sha256_file "$file") || [[ -z "$actual" ]]; then + _warn "No sha256sum/shasum/openssl found - skipping verification." + return 0 + fi + + if [[ "$actual" != "$expected" ]]; then + _err "SHA-256 mismatch for $(basename "$file")!" + _dim "expected: $expected" + _dim "actual: $actual" + _dim "Removing the file. Re-run to download it again." + rm -f "$file" + return 1 + fi + + _ok "SHA-256 verified." + return 0 +} diff --git a/linux/src/45-install.sh b/linux/src/45-install.sh new file mode 100644 index 0000000..8bf51c5 --- /dev/null +++ b/linux/src/45-install.sh @@ -0,0 +1,254 @@ +# ============================================================================== +# phpvm install +# ============================================================================== +phpvm_install() { + local ver="" no_use=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --no-use) no_use=1 ;; + -*) _err "Unknown option: $1. Usage: phpvm install [--no-use]"; return 1 ;; + *) [[ -z "$ver" ]] && ver="$1" ;; + esac + shift + done + [[ -z "$ver" ]] && { _err "Usage: phpvm install [--no-use] (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 + + # Reject non-versions up front; otherwise they only fail later as a confusing + # "Download failed" from php.net. + if [[ ! "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + _err "Invalid version '$ver'. Usage: phpvm install (e.g. phpvm install 8.3.0)" + [[ "$ver" == "composer" ]] && _dim "Did you mean: phpvm composer" + return 1 + fi + + local target="$PHPVM_VERSIONS/$ver" + + if [[ -d "$target" ]]; then + _warn "PHP $ver is already installed. Run: phpvm use $ver" + return 0 + fi + + # Check deps before attempting download + _phpvm_check_deps || return 1 + _phpvm_check_openssl_compat "$ver" || return 1 + + # Download source + local tarball="php-$ver.tar.gz" + local cache_file="$PHPVM_CACHE/$tarball" + local url="https://www.php.net/distributions/$tarball" + + if [[ ! -f "$cache_file" ]]; then + _step "Downloading PHP $ver from php.net ..." + if command -v curl &>/dev/null; then + curl -fL --progress-bar "$url" -o "$cache_file" || { + _err "Download failed. Check version: https://www.php.net/releases/" + rm -f "$cache_file" + return 1 + } + elif command -v wget &>/dev/null; then + wget -q --show-progress "$url" -O "$cache_file" || { + _err "Download failed. Check version: https://www.php.net/releases/" + rm -f "$cache_file" + return 1 + } + else + _err "curl or wget is required." + return 1 + fi + else + _dim "Using cached: $cache_file" + fi + + # Verify after both paths: a poisoned cache would otherwise be trusted + # forever, since a cached tarball never gets re-downloaded. + _step "Verifying SHA-256 ..." + _phpvm_verify_tarball "$cache_file" "$ver" || return 1 + + # Extract + local src_dir="$PHPVM_CACHE/php-$ver" + _step "Extracting ..." + [[ -d "$src_dir" ]] && rm -rf "$src_dir" + tar -xzf "$cache_file" -C "$PHPVM_CACHE" + + # Configure + mkdir -p "$target" + + # www-data doesn't exist on macOS; its stock web user is _www. Only affects + # the default user baked into php-fpm.conf, not the CLI build. + local fpm_user="www-data" + [[ "$(uname -s)" == "Darwin" ]] && fpm_user="_www" + + # gettext is keg-only on Homebrew: libintl.h isn't on the default include + # path, so --with-gettext needs the explicit prefix. Linux has it in glibc. + local gettext_opt="--with-gettext" + if [[ "$(uname -s)" == "Darwin" ]] && command -v brew &>/dev/null; then + gettext_opt="--with-gettext=$(brew --prefix gettext)" + fi + + # gmp is keg-only on Homebrew AND ships no pkg-config (.pc) file, so the + # PKG_CONFIG_PATH prepend below can't find it — configure needs the explicit + # prefix or it fails with "GNU MP Library version 4.2 or greater required". + # Linux resolves --with-gmp from the system libgmp-dev, so leave it bare. + local gmp_opt="--with-gmp" + if [[ "$(uname -s)" == "Darwin" ]] && command -v brew &>/dev/null; then + gmp_opt="--with-gmp=$(brew --prefix gmp)" + fi + + # libiconv is keg-only on Homebrew and ships no pkg-config file, so the + # PKG_CONFIG_PATH prepend below can't find it — configure needs the explicit + # prefix or iconv support fails to detect. Linux resolves iconv from glibc, + # so leave it bare. + local iconv_opt="--with-iconv" + if [[ "$(uname -s)" == "Darwin" ]] && command -v brew &>/dev/null; then + iconv_opt="--with-iconv=$(brew --prefix libiconv)" + fi + + local configure_opts=( + "--prefix=$target" + "--with-config-file-path=$target/etc" + "--with-config-file-scan-dir=$target/etc/conf.d" + "--enable-fpm" + "--with-fpm-user=$fpm_user" + "--with-fpm-group=$fpm_user" + "--enable-mbstring" + "--enable-intl" + "--enable-opcache" + "--enable-pcntl" + "--enable-bcmath" + "--enable-sockets" + "--enable-exif" + "--with-openssl" + "--with-sodium" + "--with-curl" + "--with-zlib" + "--with-readline" + "--with-zip" + "--with-pdo-mysql=mysqlnd" + "--with-pdo-sqlite" + "--with-sqlite3" + "--with-mysqli=mysqlnd" + "--enable-gd" + "--with-jpeg" + "--with-webp" + "--with-freetype" + "$gettext_opt" + "$gmp_opt" + "$iconv_opt" + "--with-pgsql" + "--with-pdo-pgsql" + "--with-onig" + "--with-pear" + ) + + local cpus + cpus=$(_phpvm_cpus) + + # One install owns the log. Every step below appends, so without this the + # file accumulates across runs and _phpvm_show_build_error's `grep -m1` + # reports the first error of an *earlier* build — exactly the wrong line + # when someone is retrying the install they just watched fail. + : > "$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; } + + # macOS: Homebrew ships openssl@3/curl/zlib/... as keg-only, so pkg-config + # can't find them on the default path. Prepend their pkgconfig dirs so + # --with-openssl et al. resolve. No-op on Linux. + if [[ "$(uname -s)" == "Darwin" ]] && command -v brew &>/dev/null; then + brew_prefix="$(brew --prefix)" + for keg in openssl@3 curl zlib libzip icu4c oniguruma readline libxml2 sqlite; do + [[ -d "$brew_prefix/opt/$keg/lib/pkgconfig" ]] && \ + PKG_CONFIG_PATH="$brew_prefix/opt/$keg/lib/pkgconfig:${PKG_CONFIG_PATH:-}" + done + export PKG_CONFIG_PATH + fi + + # >> + 2>&1 (not &>>): macOS still ships bash 3.2, which can't parse &>>. + ./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; } + ) || { + _phpvm_show_build_error + rm -rf "$target" + return 1 + } + + # Bootstrap php.ini + local etc_dir="$target/etc" + mkdir -p "$etc_dir/conf.d" + if [[ ! -f "$etc_dir/php.ini" ]]; then + local ini_src + ini_src=$(find "$src_dir" -maxdepth 1 -name "php.ini-development" | head -1) + [[ -f "$ini_src" ]] && cp "$ini_src" "$etc_dir/php.ini" + fi + + # Cleanup build dir (keep cached tarball for faster reinstall) + rm -rf "$src_dir" + + _ok "PHP $ver installed successfully." + + # 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" + else + phpvm_use "$ver" + fi + + _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) + # paste -d takes a *list* of delimiters and cycles through it, so ', ' would + # join as "a,b c". Join on a comma, then space it out. + _dim "Older patch of ${ver%.*} still installed: $(echo "$older" | paste -sd, - | sed 's/,/, /g')" + _dim "Remove it with: phpvm uninstall $newest" +} diff --git a/linux/src/50-version.sh b/linux/src/50-version.sh new file mode 100644 index 0000000..956fa13 --- /dev/null +++ b/linux/src/50-version.sh @@ -0,0 +1,128 @@ +# ============================================================================== +# phpvm use +# ============================================================================== +phpvm_use() { + local ver="$1" + [[ -z "$ver" ]] && { _err "Usage: phpvm use "; return 1; } + + local target="$PHPVM_VERSIONS/$ver" + if [[ ! -d "$target" ]]; then + _err "PHP $ver is not installed. Run: phpvm install $ver" + return 1 + fi + if [[ ! -x "$target/bin/php" ]]; then + _err "Invalid PHP $ver install: missing executable $target/bin/php" + return 1 + fi + + # Update symlink + ln -sfn "$target" "$PHPVM_CURRENT" + + # Update PATH in current shell + _phpvm_use_path + + _ok "Now using PHP $ver" + 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\"" +} + +# ============================================================================== +# phpvm list +# ============================================================================== +phpvm_list() { + echo "" + if [[ ! -d "$PHPVM_VERSIONS" ]] || [[ -z "$(ls -A "$PHPVM_VERSIONS" 2>/dev/null)" ]]; then + _dim "No PHP versions installed." + echo "" + return 0 + fi + + local current + current=$(_phpvm_current_version) + + echo -e " \033[36mInstalled versions:\033[0m" + for dir in "$PHPVM_VERSIONS"/*/; do + local v + v=$(basename "$dir") + if [[ "$v" == "$current" ]]; then + echo -e " \033[32m-> $v (active)\033[0m" + else + echo -e " \033[90m $v\033[0m" + fi + done + echo "" +} + +# ============================================================================== +# phpvm current +# ============================================================================== +phpvm_current() { + local cur + cur=$(_phpvm_current_version) + if [[ -n "$cur" ]]; then + echo "" + echo -e " \033[32mActive: $cur\033[0m" + php --version 2>/dev/null | sed 's/^/ /' + echo "" + else + _warn "No PHP version active. Run: phpvm use " + fi +} + +# ============================================================================== +# phpvm uninstall +# ============================================================================== +phpvm_uninstall() { + local ver="$1" + [[ -z "$ver" ]] && { _err "Usage: phpvm uninstall "; return 1; } + + local target="$PHPVM_VERSIONS/$ver" + [[ ! -d "$target" ]] && { _err "PHP $ver is not installed."; return 1; } + + local current + current=$(_phpvm_current_version) + if [[ "$current" == "$ver" ]]; then + _err "Cannot uninstall the active version. Switch first: phpvm use " + return 1 + fi + + rm -rf "$target" + _ok "PHP $ver removed." +} + +# ============================================================================== +# phpvm which +# ============================================================================== +phpvm_which() { + if command -v php &>/dev/null; then + _ok "$(command -v php)" + else + _warn "php not in PATH" + fi +} + +# ============================================================================== +# phpvm ini +# ============================================================================== +phpvm_ini() { + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local ini="$PHPVM_VERSIONS/$cur/etc/php.ini" + if [[ -f "$ini" ]]; then + _step "Opening $ini" + "${EDITOR:-nano}" "$ini" + else + _err "php.ini not found: $ini" + fi +} + +# ============================================================================== +# phpvm deps — print dependency install command for current OS +# ============================================================================== +phpvm_deps() { + _phpvm_print_dep_install +} diff --git a/linux/src/55-auto.sh b/linux/src/55-auto.sh new file mode 100644 index 0000000..b47824a --- /dev/null +++ b/linux/src/55-auto.sh @@ -0,0 +1,154 @@ +# ── Auto-switch (.phpvmrc) ──────────────────────────────────────────────────── +# Walk from $1 (default $PWD) up to / looking for .phpvmrc. +# shellcheck disable=SC2120 # optional arg with PWD default - tests pass an arg. +_phpvm_find_rc() { + local dir="${1:-$PWD}" + while [[ -n "$dir" ]]; do + if [[ -f "$dir/.phpvmrc" ]]; then + echo "$dir/.phpvmrc" + return 0 + fi + [[ "$dir" == "/" ]] && return 1 + dir=$(dirname "$dir") + done + return 1 +} + +# First non-comment, non-empty line; strip inline #-comments and a leading `v`. +_phpvm_read_rc() { + local file="$1" + [[ -f "$file" ]] || return 1 + local line + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + if [[ -n "$line" ]]; then + echo "${line#v}" + return 0 + fi + done < "$file" + return 1 +} + +# Map an rc version onto an installed version dir. Full semver passes through +# if installed; major.minor picks the highest installed patch. +_phpvm_resolve_rc() { + local requested="$1" + [[ -z "$requested" ]] && return 1 + if [[ -d "$PHPVM_VERSIONS/$requested/bin" ]]; then + echo "$requested" + return 0 + fi + if [[ "$requested" =~ ^[0-9]+\.[0-9]+$ ]]; then + local match + match=$(find "$PHPVM_VERSIONS" -mindepth 1 -maxdepth 1 -type d -name "${requested}.*" 2>/dev/null \ + | while read -r d; do [[ -x "$d/bin/php" ]] && basename "$d"; done \ + | sort -V | tail -1) + if [[ -n "$match" ]]; then + echo "$match" + return 0 + fi + fi + return 1 +} + +# Apply .phpvmrc to current shell. Session-only PATH change; tracked via +# $PHPVM_AUTO_ACTIVE so repeat calls no-op and leaving a project cleans up. +# Flags: -s / --silent for hook usage. +_phpvm_auto() { + local silent=0 + [[ "$1" == "-s" || "$1" == "--silent" ]] && silent=1 + + local rc resolved requested + if ! rc=$(_phpvm_find_rc); then + if [[ -n "${PHPVM_AUTO_ACTIVE:-}" ]]; then + local old="$PHPVM_VERSIONS/$PHPVM_AUTO_ACTIVE/bin" + PATH=$(echo "$PATH" | tr ':' '\n' | grep -vxF "$old" | paste -sd ':' -) + export PATH + unset PHPVM_AUTO_ACTIVE + [[ $silent -eq 0 ]] && _dim "Cleared auto PHP (no .phpvmrc upstream)." + fi + return 0 + fi + + if ! requested=$(_phpvm_read_rc "$rc"); then + [[ $silent -eq 0 ]] && _warn "$rc is empty or comment-only." + return 0 + fi + + if ! resolved=$(_phpvm_resolve_rc "$requested"); then + [[ $silent -eq 0 ]] && { + _warn "PHP $requested (from $rc) is not installed." + _dim "Run: phpvm install $requested" + } + return 0 + fi + + [[ "${PHPVM_AUTO_ACTIVE:-}" == "$resolved" ]] && return 0 + + if [[ -n "${PHPVM_AUTO_ACTIVE:-}" ]]; then + local old="$PHPVM_VERSIONS/$PHPVM_AUTO_ACTIVE/bin" + PATH=$(echo "$PATH" | tr ':' '\n' | grep -vxF "$old" | paste -sd ':' -) + fi + + local new="$PHPVM_VERSIONS/$resolved/bin" + export PATH="$new:$PATH" + export PHPVM_AUTO_ACTIVE="$resolved" + if [[ $silent -eq 0 ]]; then _ok "Auto-switched to PHP $resolved (from $rc)"; fi + 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 +} + +# ============================================================================== +# AUTO-SWITCH COMMANDS (phpvm auto, phpvm hook) +# ============================================================================== +phpvm_auto() { + _phpvm_auto +} + +phpvm_hook() { + local sub="${1:-status}" + local flag="$PHPVM_DIR/.auto-hook" + case "$sub" in + enable) + mkdir -p "$PHPVM_DIR" + touch "$flag" + _ok "Hook enabled. Restart your shell, or run:" + _dim " source \"$PHPVM_DIR/phpvm.sh\"" + ;; + disable) + if [[ -f "$flag" ]]; then + rm -f "$flag" + _ok "Hook disabled. Restart your shell to fully unregister." + else + _warn "Hook is not enabled." + fi + ;; + status) + if [[ -f "$flag" ]]; then + _ok "Hook is enabled ($flag)" + else + _dim "Hook is disabled. Run: phpvm hook enable" + fi + ;; + *) + echo "" + echo " phpvm hook - manage the shell auto-switch hook" + echo " phpvm hook enable Enable .phpvmrc auto-switching on cd" + echo " phpvm hook disable Disable the hook" + echo " phpvm hook status Check whether the hook is enabled" + echo "" + ;; + esac +} diff --git a/linux/src/60-ext.sh b/linux/src/60-ext.sh new file mode 100644 index 0000000..43caa6d --- /dev/null +++ b/linux/src/60-ext.sh @@ -0,0 +1,345 @@ +# ============================================================================== +# EXT COMMANDS +# ============================================================================== + +# Everything PHP could load, with its current state - the Windows `ext list` +# shape. The ON side has to come from `php -m` rather than from a directory +# listing, because extensions compiled into the binary (pdo, mbstring, ...) have +# no .so to find; the OFF side is the .so files nothing has switched on yet. +phpvm_ext_list() { + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + + local loaded + loaded=$("$php_bin" -m 2>/dev/null | grep -v '^\[' | grep -v '^[[:space:]]*$' \ + | tr '[:upper:]' '[:lower:]' | sort -u) + + local ext_dir available="" + ext_dir=$("$php_bin" -r "echo ini_get('extension_dir');" 2>/dev/null) + if [[ -n "$ext_dir" && -d "$ext_dir" ]]; then + available=$(find "$ext_dir" -maxdepth 1 -name '*.so' 2>/dev/null \ + | while read -r so; do + so=$(basename "$so" .so) + printf '%s\n' "${so#php_}" + done | tr '[:upper:]' '[:lower:]' | sort -u) + fi + + echo "" + echo -e " \033[36mPHP $cur — extensions:\033[0m" + echo "" + + local on=0 off=0 name + while read -r name; do + [[ -z "$name" ]] && continue + if printf '%s\n' "$loaded" | grep -qx -- "$name"; then + printf " \033[32m%-20s ON\033[0m\n" "$name" + on=$((on+1)) + else + printf " \033[90m%-20s OFF (.so available)\033[0m\n" "$name" + off=$((off+1)) + fi + done <<< "$(printf '%s\n%s\n' "$loaded" "$available" | grep -v '^[[:space:]]*$' | sort -u)" + + echo "" + _dim "$on ON, $off OFF. Enable: phpvm ext enable " + echo "" +} + +# `php -m` verbatim - what the runtime actually has loaded, nothing inferred. +phpvm_ext_loaded() { + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + + echo "" + echo -e " \033[36mPHP $cur — loaded extensions:\033[0m" + "$php_bin" -m 2>/dev/null | grep -v '^\[' | grep -v '^[[:space:]]*$' | sort | while read -r ext; do + echo -e " \033[32m$ext\033[0m" + done + echo "" +} + +phpvm_ext_install() { + local name="$1" + [[ -z "$name" ]] && { _err "Usage: phpvm ext install [version]"; return 1; } + + command -v pecl &>/dev/null || { + _err "pecl not found. It should be installed with PHP." + _dim "Try: phpvm use first." + return 1 + } + + _phpvm_ext_preflight "$name" || return 1 + + local ver="${2:-}" + if [[ -n "$ver" ]]; then + _step "Installing $name-$ver via PECL ..." + pecl install "$name-$ver" || { + _err "Failed to install $name-$ver via PECL." + _phpvm_ext_runtime_notes "$name" + return 1 + } + else + _step "Installing $name via PECL ..." + pecl install "$name" || { + _err "Failed to install $name via PECL." + _phpvm_ext_runtime_notes "$name" + return 1 + } + fi + + _ok "Done. Enable with: phpvm ext enable $name" + _phpvm_ext_runtime_notes "$name" +} + +# Build-time prerequisites for specific extensions. Warn early instead of +# letting `pecl install` fail mid-build with cryptic compiler errors. +_phpvm_ext_preflight() { + local name="$1" + case "$name" in + sqlsrv|pdo_sqlsrv) + # unixODBC headers (sql.h / sqlext.h) are required to compile sqlsrv. + if command -v odbc_config &>/dev/null; then return 0; fi + local inc + for inc in /usr/include/sql.h /usr/local/include/sql.h \ + /opt/homebrew/include/sql.h /opt/homebrew/opt/unixodbc/include/sql.h \ + /usr/local/opt/unixodbc/include/sql.h; do + [[ -f "$inc" ]] && return 0 + done + _warn "unixODBC development headers not found — required to build $name." + local pm + pm=$(_phpvm_detect_os) + case "$pm" in + apt) _dim "Install: sudo apt-get install -y unixodbc-dev" ;; + dnf) _dim "Install: sudo dnf install -y unixODBC-devel" ;; + yum) _dim "Install: sudo yum install -y unixODBC-devel" ;; + pacman) _dim "Install: sudo pacman -S --needed unixodbc" ;; + zypper) _dim "Install: sudo zypper install -y unixODBC-devel" ;; + brew) _dim "Install: brew install unixodbc" ;; + *) _dim "Install unixODBC development headers via your package manager." ;; + esac + _dim "Then retry: phpvm ext install $name" + return 1 + ;; + esac + return 0 +} + +# Post-install advisories for extensions that need extra system components. +_phpvm_ext_runtime_notes() { + local name="$1" + case "$name" in + sqlsrv|pdo_sqlsrv) + echo "" + _dim "Note: $name also requires the Microsoft ODBC Driver for SQL Server" + _dim "to actually connect at runtime. Install (one-off, system-wide):" + _dim " https://learn.microsoft.com/sql/connect/odbc/linux-mac/installing-the-microsoft-odbc-driver-for-sql-server" + _dim "Setup guide: https://learn.microsoft.com/sql/connect/php/step-1-configure-development-environment-for-php-development" + ;; + esac +} + +phpvm_ext_enable() { + local name="$1" + [[ -z "$name" ]] && { _err "Usage: phpvm ext enable "; return 1; } + + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "Invalid PHP $cur install: missing executable $php_bin"; return 1; } + + local conf_dir="$PHPVM_VERSIONS/$cur/etc/conf.d" + local ini_file="$conf_dir/$name.ini" + + mkdir -p "$conf_dir" + + if "$php_bin" -r "exit(extension_loaded('$name') ? 0 : 1);" 2>/dev/null; then + _warn "$name is already loaded." + return 0 + fi + + local ext_dir ext_path="" + ext_dir=$("$php_bin" -r "echo ini_get('extension_dir');" 2>/dev/null) + for candidate in "$ext_dir/$name.so" "$ext_dir/php_$name.so"; do + if [[ -f "$candidate" ]]; then + ext_path="$candidate" + break + fi + done + + if [[ -z "$ext_path" ]]; then + _err "Extension not found in PHP extension_dir: $name" + _dim "Install it first: phpvm ext install $name" + return 1 + fi + + if [[ -f "$ini_file" ]]; then + _warn "$name.ini already exists in conf.d." + return 0 + fi + + # Determine if zend_extension (xdebug, opcache) or regular extension + local prefix="extension" + case "$name" in xdebug|opcache|ioncube_loader) prefix="zend_extension" ;; esac + + echo "$prefix=$name" > "$ini_file" + _ok "Enabled: $name ($ini_file)" + _dim "Verify: php -m | grep $name" +} + +phpvm_ext_disable() { + local name="$1" + [[ -z "$name" ]] && { _err "Usage: phpvm ext disable "; return 1; } + + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local conf_dir="$PHPVM_VERSIONS/$cur/etc/conf.d" + local ini_file="$conf_dir/$name.ini" + + if [[ -f "$ini_file" ]]; then + rm -f "$ini_file" + _ok "Disabled: $name (removed $ini_file)" + else + # Try editing main php.ini + local main_ini="$PHPVM_VERSIONS/$cur/etc/php.ini" + if [[ -f "$main_ini" ]]; then + sed -i "s/^\(extension\|zend_extension\)=$name/;\1=$name/" "$main_ini" + sed -i "s/^\(extension\|zend_extension\)=php_$name\.so/;\1=php_$name.so/" "$main_ini" + _ok "Disabled: $name (commented in php.ini)" + else + _warn "$name not found in conf.d or php.ini." + fi + fi +} + +phpvm_ext_info() { + local name="$1" + [[ -z "$name" ]] && { _err "Usage: phpvm ext info "; return 1; } + + echo "" + php -r " +if (extension_loaded('$name')) { + \$r = new ReflectionExtension('$name'); + echo 'Name : ' . \$r->getName() . PHP_EOL; + echo 'Version : ' . (\$r->getVersion() ?? 'n/a') . PHP_EOL; + \$c = \$r->getClassNames(); + if (\$c) echo 'Classes : ' . implode(', ', \$c) . PHP_EOL; +} else { + echo 'Not loaded. Run: phpvm ext enable $name' . PHP_EOL; +} +" 2>/dev/null | while read -r line; do echo " $line"; done + echo "" +} + +phpvm_ext_laravel() { + local preset="${1:-full}" + case "$preset" in min|minimal) preset="minimal" ;; *) preset="full" ;; esac + + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + + local minimal=(openssl pdo pdo_mysql pdo_sqlite mbstring tokenizer xml ctype fileinfo bcmath curl zip sodium) + local extra=(intl gd exif opcache pdo_pgsql pgsql sockets) + local pecl=(redis) + + local enable_list=("${minimal[@]}") + local pecl_list=() + if [[ "$preset" != "minimal" ]]; then + enable_list+=("${extra[@]}") + pecl_list+=("${pecl[@]}") + fi + + echo "" + echo -e " \033[36mLaravel extension setup ($preset) — PHP $cur\033[0m" + echo -e " \033[90m─────────────────────────────────────────────────\033[0m" + echo "" + + local loaded + loaded=$("$php_bin" -m 2>/dev/null | tr '[:upper:]' '[:lower:]') + + local ext_dir + ext_dir=$("$php_bin" -r "echo ini_get('extension_dir');" 2>/dev/null) + + echo -e " \033[33m[1/2] Enabling bundled extensions ...\033[0m" + for ext in "${enable_list[@]}"; do + if echo "$loaded" | grep -qx "$ext"; then + printf " %-18s already ON\n" "$ext" + continue + fi + # Built-in extensions (e.g. pdo, mbstring) have no .so file; try enable anyway. + if [[ -n "$ext_dir" && ! -f "$ext_dir/$ext.so" && ! -f "$ext_dir/php_$ext.so" ]]; then + # Check if it's a static built-in via php -m again (case-insensitive match above already failed) + printf " \033[90mskip %-18s (not built into this PHP)\033[0m\n" "$ext" + continue + fi + if phpvm_ext_enable "$ext" >/dev/null 2>&1; then + _ok "Enabled: $ext" + else + _warn "Could not enable: $ext" + fi + done + + if [[ ${#pecl_list[@]} -gt 0 ]]; then + echo "" + echo -e " \033[33m[2/2] PECL extensions ...\033[0m" + for ext in "${pecl_list[@]}"; do + if echo "$loaded" | grep -qx "$ext"; then + printf " %-18s already ON\n" "$ext" + continue + fi + if [[ -f "$ext_dir/$ext.so" || -f "$ext_dir/php_$ext.so" ]]; then + phpvm_ext_enable "$ext" >/dev/null 2>&1 && _ok "Enabled: $ext" + else + _step "Installing $ext via PECL ..." + if phpvm_ext_install "$ext"; then + phpvm_ext_enable "$ext" >/dev/null 2>&1 && _ok "Enabled: $ext" + fi + fi + done + fi + + echo "" + _ok "Done! Verify with: php -m" + echo "" + if [[ "$preset" == "minimal" ]]; then + _dim "For Redis + GD + opcache + intl, run: phpvm ext laravel full" + else + _dim "Optional extras:" + _dim " phpvm ext install xdebug # debugger" + _dim " phpvm ext install imagick # advanced image processing" + _dim " phpvm composer # install Composer" + fi + echo "" +} + +phpvm_ext() { + local sub="${1:-help}" + local name="${2:-}" + local ver="${3:-}" + + case "$sub" in + list|ls) phpvm_ext_list ;; + loaded) phpvm_ext_loaded ;; + install) phpvm_ext_install "$name" "$ver" ;; + enable) phpvm_ext_enable "$name" ;; + disable) phpvm_ext_disable "$name" ;; + info) phpvm_ext_info "$name" ;; + laravel) phpvm_ext_laravel "$name" ;; + help|*) phpvm_ext_help ;; + esac +} diff --git a/linux/src/70-composer.sh b/linux/src/70-composer.sh new file mode 100644 index 0000000..90d2a95 --- /dev/null +++ b/linux/src/70-composer.sh @@ -0,0 +1,83 @@ +# ============================================================================== +# COMPOSER (one global composer that follows the active PHP version) +# ============================================================================== +phpvm_composer() { + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version. Run: phpvm use "; return 1; } + + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + + local phar="$PHPVM_DIR/composer.phar" + local shim="$PHPVM_BIN/composer" + + if [[ -x "$shim" && -f "$phar" ]]; then + _warn "Composer already installed at $shim" + _dim "It follows your active PHP version automatically." + _dim "Run: composer --version" + return 0 + fi + + # openssl is bundled on most distros' PHP-from-source builds; warn if missing. + if ! "$php_bin" -r "exit(extension_loaded('openssl') ? 0 : 1);" 2>/dev/null; then + _warn "openssl extension not loaded; Composer requires it." + _dim "Enable it then retry: phpvm ext enable openssl" + fi + + local installer_url="https://getcomposer.org/installer" + local sig_url="https://composer.github.io/installer.sig" + local tmp; tmp=$(mktemp -t composer-setup.XXXXXX.php) || { _err "mktemp failed."; return 1; } + + _step "Downloading Composer installer ..." + if command -v curl &>/dev/null; then + curl -fsSL "$installer_url" -o "$tmp" || { _err "Download failed."; rm -f "$tmp"; return 1; } + elif command -v wget &>/dev/null; then + wget -qO "$tmp" "$installer_url" || { _err "Download failed."; rm -f "$tmp"; return 1; } + else + _err "curl or wget is required."; rm -f "$tmp"; return 1 + fi + + _step "Verifying installer integrity ..." + local expected actual + if command -v curl &>/dev/null; then + expected=$(curl -fsSL "$sig_url" 2>/dev/null | tr -d '[:space:]') + else + expected=$(wget -qO- "$sig_url" 2>/dev/null | tr -d '[:space:]') + fi + actual=$("$php_bin" -r "echo hash_file('sha384', '$tmp');" 2>/dev/null) + + if [[ -z "$expected" || "$actual" != "$expected" ]]; then + _err "Hash mismatch! Installer may be corrupt or tampered." + rm -f "$tmp" + return 1 + fi + _ok "Hash verified." + + _step "Installing Composer ..." + mkdir -p "$PHPVM_BIN" + (cd "$PHPVM_DIR" && "$php_bin" "$tmp" --quiet --filename=composer.phar) || { + _err "Composer installer failed." + rm -f "$tmp" + return 1 + } + rm -f "$tmp" + + [[ ! -f "$phar" ]] && { _err "composer.phar not created at $phar"; return 1; } + + # POSIX shim — execs the *current* PHP, so composer tracks `phpvm use` + # without reinstalling. Works in bash/zsh/sh. + cat > "$shim" </dev/null | sed 's/^/ /' + echo "" + _dim "Composer follows your active PHP version — no need to re-run after 'phpvm use'." +} diff --git a/linux/src/72-wpcli.sh b/linux/src/72-wpcli.sh new file mode 100644 index 0000000..5ef8a09 --- /dev/null +++ b/linux/src/72-wpcli.sh @@ -0,0 +1,66 @@ +# ============================================================================== +# WP-CLI (one global wp that follows the active PHP version) +# ============================================================================== +phpvm_wp_cli() { + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version. Run: phpvm use "; return 1; } + + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + + local phar="$PHPVM_DIR/wp-cli.phar" + local shim="$PHPVM_BIN/wp" + + if [[ -x "$shim" && -f "$phar" ]]; then + _warn "WP-CLI already installed at $shim" + _dim "It follows your active PHP version automatically." + _dim "Run: wp --version" + return 0 + fi + + local phar_url="https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar" + local hash_url="$phar_url.sha512" + + _step "Downloading WP-CLI ..." + if command -v curl &>/dev/null; then + curl -fsSL "$phar_url" -o "$phar" || { _err "Download failed."; rm -f "$phar"; return 1; } + elif command -v wget &>/dev/null; then + wget -qO "$phar" "$phar_url" || { _err "Download failed."; rm -f "$phar"; return 1; } + else + _err "curl or wget is required."; return 1 + fi + + _step "Verifying SHA-512 ..." + local expected actual + if command -v curl &>/dev/null; then + expected=$(curl -fsSL "$hash_url" 2>/dev/null | awk '{print $1}') + else + expected=$(wget -qO- "$hash_url" 2>/dev/null | awk '{print $1}') + fi + # Hash through PHP itself (always present) — no sha512sum/shasum coreutils + # split between GNU and BSD/macOS. + actual=$("$php_bin" -r "echo hash_file('sha512', '$phar');" 2>/dev/null) + + if [[ -z "$expected" || "$actual" != "$expected" ]]; then + _err "SHA-512 mismatch! Phar may be corrupt or tampered." + rm -f "$phar" + return 1 + fi + _ok "SHA-512 verified." + + mkdir -p "$PHPVM_BIN" + cat > "$shim" </dev/null | sed 's/^/ /' + echo "" + _dim "WP-CLI follows your active PHP version — no need to re-run after 'phpvm use'." +} diff --git a/linux/src/80-maint.sh b/linux/src/80-maint.sh new file mode 100644 index 0000000..8a67405 --- /dev/null +++ b/linux/src/80-maint.sh @@ -0,0 +1,242 @@ +# ============================================================================== +# phpvm doctor — read-only health check (never mutates state) +# ============================================================================== +phpvm_doctor() { + echo "" + echo -e " \033[36mphpvm doctor — environment health check\033[0m" + echo -e " \033[36m─────────────────────────────────────────────────────────\033[0m" + + local ok=0 warn=0 + _dok() { printf ' \033[32m[ok] %s\033[0m\n' "$*"; ok=$((ok+1)); } + _dwarn(){ printf ' \033[33m[warn] %s\033[0m\n' "$*"; warn=$((warn+1)); } + + # 1. Active version + symlink health. + local cur + cur=$(_phpvm_current_version) + if [[ -n "$cur" ]]; then + _dok "Active PHP version: $cur" + else + _dwarn "No active PHP version. Run: phpvm use " + fi + + # 2. PATH: whichever php resolves first is what runs - but a second PHP + # further down PATH still matters, because it is what comes back the + # moment phpvm's bin drops off (a distro upgrade rewriting the rc, a + # shell that never sourced phpvm.sh). `command -v` only ever reports the + # winner, so walk PATH ourselves. + # + # Split with parameter expansion rather than tr/awk: this is the check + # that tells you PATH is broken, so it must not itself depend on finding + # coreutils there. It also sidesteps zsh, where `for d in $PATH` does not + # split on colons at all. + local php_paths="" rest="$PATH" d + while [[ -n "$rest" ]]; do + d="${rest%%:*}" + if [[ "$d" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi + [[ -n "$d" && -x "$d/php" ]] || continue + case ":$php_paths:" in *":$d/php:"*) continue ;; esac # PATH may repeat + php_paths="${php_paths:+$php_paths:}$d/php" + done + + if [[ -z "$php_paths" ]]; then + _dwarn "No 'php' on PATH. Run: phpvm use (and source phpvm.sh in your rc)." + else + local first="${php_paths%%:*}" + case "$first" in + "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) + _dok "'php' resolves to phpvm: $first" ;; + *) + _dwarn "'php' resolves to a non-phpvm install: $first" + _dim "Ensure $PHPVM_DIR is sourced in your shell rc, then open a new shell." ;; + esac + + local other="" p + rest="$php_paths" + while [[ -n "$rest" ]]; do + p="${rest%%:*}" + if [[ "$p" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi + case "$p" in + # `:` rather than an empty body - bash 3.2 on macOS is fussy + # about case arms, which is what broke the first cut of this. + "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) : ;; + *) other="$p"; break ;; + esac + done + if [[ -n "$other" && "$other" != "$first" ]]; then + _dwarn "Another PHP on PATH: $other" + _dim "It shadows phpvm whenever phpvm's bin is not first. Remove it or reorder PATH." + fi + fi + + # 3. extension_dir must match what the active build was compiled with. + # Checking the directory merely exists passes an ini left pointing at a + # different version - the exact case fix-ini exists to repair. Go through + # the version's own binary, not PATH: check 2 may have just told us PATH + # resolves somewhere else entirely. + if [[ -n "$cur" ]]; then + local doc_php="$PHPVM_VERSIONS/$cur/bin/php" + if [[ ! -x "$doc_php" ]]; then + _dwarn "php binary missing for active version: $doc_php" + else + local ext_dir built_dir + ext_dir=$("$doc_php" -r "echo ini_get('extension_dir');" 2>/dev/null) + built_dir=$("$doc_php" -r "echo PHP_EXTENSION_DIR;" 2>/dev/null) + if [[ -z "$ext_dir" ]]; then + _dwarn "No extension_dir set in the active php.ini." + _dim "Fix with: phpvm fix-ini" + elif [[ "${ext_dir%/}" != "${built_dir%/}" ]]; then + _dwarn "extension_dir mismatch: '$ext_dir' != '$built_dir'" + _dim "Fix with: phpvm fix-ini" + elif [[ ! -d "$ext_dir" ]]; then + _dwarn "extension_dir does not exist: $ext_dir" + _dim "Fix with: phpvm fix-ini" + else + _dok "extension_dir matches active build." + fi + fi + fi + + # 4. OpenSSL in PHP (HTTPS for composer / ext downloads). + if [[ -n "$cur" ]] && php -m 2>/dev/null | grep -qi '^openssl$'; then + _dok "openssl extension loaded (HTTPS OK)." + elif [[ -n "$cur" ]]; then + _dwarn "openssl not loaded — composer/HTTPS may fail." + _dim "Enable with: phpvm ext enable openssl" + fi + + # 5. Build toolchain (needed for future installs — build from source). + local missing=() + local t + for t in gcc make autoconf bison re2c pkg-config; do + command -v "$t" &>/dev/null || missing+=("$t") + done + if [[ ${#missing[@]} -eq 0 ]]; then + _dok "Build toolchain present (install/build ready)." + else + _dwarn "Missing build tools: ${missing[*]}" + _dim "See: phpvm deps" + fi + + # 6. Host OpenSSL vs what is still buildable here. `install` already refuses + # PHP <8.1 on OpenSSL 3 (_phpvm_check_openssl_compat), but only once you + # have waited for a download - doctor should say it upfront. + local host_ssl + if host_ssl=$(_phpvm_openssl_version) && [[ -n "$host_ssl" ]]; then + local ssl_major="${host_ssl%%.*}" + if [[ "$ssl_major" =~ ^[0-9]+$ ]] && (( ssl_major >= 3 )); then + _dwarn "OpenSSL $host_ssl - PHP 8.0 and older cannot be built on this host." + _dim "Installed versions keep working; only new builds below 8.1 are refused." + else + _dok "OpenSSL $host_ssl (all supported PHP versions buildable)." + fi + else + _dim " OpenSSL version could not be determined - build compatibility unknown." + fi + + echo "" + if [[ $warn -eq 0 ]]; then + _ok "All checks passed ($ok ok)." + else + _warn "$warn warning(s), $ok ok. See fixes above." + fi + echo "" + + unset -f _dok _dwarn 2>/dev/null +} + +# ============================================================================== +# FIX-INI (sync extension_dir in active php.ini) +# ============================================================================== +phpvm_fix_ini() { + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version. Run: phpvm use "; return 1; } + + local target_dir="$PHPVM_VERSIONS/$cur" + local php_bin="$target_dir/bin/php" + local ini="$target_dir/etc/php.ini" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + [[ ! -f "$ini" ]] && { _err "php.ini not found: $ini"; return 1; } + + # Resolve the real extension_dir compiled into PHP. + local ext_path + ext_path=$("$php_bin" -r "echo PHP_EXTENSION_DIR;" 2>/dev/null) + [[ -z "$ext_path" ]] && { _err "Could not resolve PHP_EXTENSION_DIR."; return 1; } + + if grep -qE '^\s*;?\s*extension_dir\s*=' "$ini"; then + # In-place edit, escaping path for sed (/ in path). + local esc + esc=$(printf '%s' "$ext_path" | sed 's:[\\/&]:\\&:g') + sed -i.bak -E "s|^[[:space:]]*;?[[:space:]]*extension_dir[[:space:]]*=.*$|extension_dir = \"$esc\"|" "$ini" + rm -f "$ini.bak" + _ok "Fixed extension_dir → $ext_path" + else + printf '\nextension_dir = "%s"\n' "$ext_path" >> "$ini" + _ok "Added extension_dir → $ext_path" + fi + + _dim "Verify: phpvm ext list" +} + +# ============================================================================== +# SELF UPDATE +# ============================================================================== +phpvm_upgrade() { + local script_url="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/linux/phpvm.sh" + local version_url="https://raw.githubusercontent.com/devhardiyanto/phpvm/main/version.txt" + local script_dest="$PHPVM_DIR/phpvm.sh" + local backup="$PHPVM_DIR/phpvm.sh.bak" + + _step "Checking latest version ..." + + local latest + if command -v curl &>/dev/null; then + latest=$(curl -fsSL --max-time 5 "$version_url" 2>/dev/null | tr -d '[:space:]') + elif command -v wget &>/dev/null; then + latest=$(wget -qO- --timeout=5 "$version_url" 2>/dev/null | tr -d '[:space:]') + else + _err "curl or wget is required." + return 1 + fi + + if [[ -z "$latest" ]]; then + _err "Could not reach GitHub. Check your connection." + return 1 + fi + + # Compare versions + local newer + newer=$(printf '%s\n%s' "$PHPVM_VERSION" "$latest" | sort -V | tail -1) + if [[ "$newer" == "$PHPVM_VERSION" && "$latest" == "$PHPVM_VERSION" ]]; then + _ok "Already up to date. (phpvm $PHPVM_VERSION)" + return 0 + fi + + _step "Upgrading phpvm $PHPVM_VERSION → $latest ..." + + # Backup + cp "$script_dest" "$backup" + _dim "Backup saved: $backup" + + # Download new version + local tmp="$PHPVM_DIR/phpvm.sh.tmp" + if command -v curl &>/dev/null; then + curl -fsSL "$script_url" -o "$tmp" || { _err "Download failed."; return 1; } + else + wget -qO "$tmp" "$script_url" || { _err "Download failed."; return 1; } + fi + + # Verify it looks like a valid phpvm script + if ! grep -q "PHPVM_VERSION" "$tmp"; then + _err "Downloaded file seems invalid. Rolling back." + rm -f "$tmp" + return 1 + fi + + mv "$tmp" "$script_dest" + chmod +x "$script_dest" + + _ok "phpvm upgraded to $latest!" + _dim "Run: source ~/.bashrc (or restart terminal)" + _dim "Backup of old version: $backup" +} diff --git a/linux/src/90-help.sh b/linux/src/90-help.sh new file mode 100644 index 0000000..50c7109 --- /dev/null +++ b/linux/src/90-help.sh @@ -0,0 +1,147 @@ +# ============================================================================== +# HELP +# ============================================================================== + +phpvm_ext_help() { + cat < Enable via conf.d ini drop-in + phpvm ext disable Disable extension + phpvm ext install Install via PECL + phpvm ext install Install specific version via PECL + phpvm ext info Extension details + phpvm ext laravel Enable all Laravel extensions (full) + phpvm ext laravel minimal Enable required Laravel extensions only + phpvm ext laravel full Enable required + recommended + Redis + + Examples: + phpvm ext install redis + phpvm ext install xdebug + phpvm ext install imagick 3.7.0 + phpvm ext enable opcache + phpvm ext disable xdebug + phpvm ext info redis + +EOF +} + +phpvm_help() { + cat < Build & install a PHP version + --no-use install without switching to it + phpvm use Switch the active PHP version + phpvm list List installed versions + phpvm current Show active version info + phpvm uninstall Remove a PHP version + phpvm which Path to active php binary + phpvm ini Open active php.ini in \$EDITOR + phpvm fix-ini Sync extension_dir in active php.ini + phpvm deps Print dependency install command + phpvm doctor Diagnose PATH, extension_dir, openssl, toolchain + + COMPOSER / WP-CLI + phpvm composer Install Composer for active PHP version + phpvm wp-cli Install WP-CLI (global 'wp' command) + + AUTO-SWITCH (.phpvmrc) + phpvm auto Switch to the version named in .phpvmrc + phpvm hook enable Enable auto-switching on cd (bash/zsh) + phpvm hook disable Disable the hook + phpvm hook status Check whether the hook is enabled + + SELF UPDATE + phpvm upgrade Upgrade phpvm to latest version + phpvm version Show current phpvm version + + LARAVEL QUICK SETUP + phpvm ext laravel Enable all Laravel extensions (full) + phpvm ext laravel minimal Required extensions only + phpvm ext laravel full Required + recommended + Redis + + EXTENSION MANAGEMENT + phpvm ext list Available extensions (ON/OFF) + phpvm ext loaded Loaded extensions (php -m) + phpvm ext enable Enable extension (conf.d drop-in) + phpvm ext disable Disable extension + phpvm ext install Install via PECL + phpvm ext info Extension details + phpvm ext help Full ext reference + + EXAMPLES + phpvm install 8.3.0 + phpvm use 8.3.0 + phpvm ext install redis + phpvm ext install xdebug + phpvm ext enable opcache + + Home: $PHPVM_DIR + Build log: $PHPVM_LOG + +EOF +} + +# ============================================================================== +# 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 + # Indices are shifted by one so the array starts at 1: zsh arrays are + # 1-based and reject row[0] outright ("assignment to invalid subscript + # range"), while bash simply leaves index 0 unused. + local -a row + for (( j = 0; j <= lb; j++ )); do row[j+1]=$j; done + for (( i = 1; i <= la; i++ )); do + prev=${row[1]} + row[1]=$i + for (( j = 1; j <= lb; j++ )); do + cur=${row[j+1]} + # ${a:i-1:1} makes zsh read ":i" as a history modifier + # ("unrecognized modifier"). Spell the offset arithmetic out. + if [[ "${a:$((i-1)):1}" == "${b:$((j-1)):1}" ]]; then cost=0; else cost=1; fi + del=$(( row[j+1] + 1 )); ins=$(( row[j] + 1 )); sub=$(( prev + cost )) + min=$del + (( ins < min )) && min=$ins + (( sub < min )) && min=$sub + row[j+1]=$min + prev=$cur + done + done + echo "${row[lb+1]}" +} + +# Canonical command list (includes aliases) for suggestions. +# An array, not a space-separated string: zsh does not word-split unquoted +# parameters, so `for c in $_PHPVM_COMMANDS` handed the whole list over as a +# single candidate and every typo was answered with the entire list. +_PHPVM_COMMANDS=(install use list ls current uninstall remove which doctor ini + deps ext composer wp-cli 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 +} diff --git a/linux/src/99-entry.sh b/linux/src/99-entry.sh new file mode 100644 index 0000000..f984a1e --- /dev/null +++ b/linux/src/99-entry.sh @@ -0,0 +1,57 @@ +# ============================================================================== +# ENTRY POINT +# ============================================================================== +phpvm() { + _phpvm_init + _phpvm_check_update + + local cmd="${1:-help}" + shift || true + + case "$cmd" in + install) phpvm_install "$@" ;; + use) phpvm_use "$@" ;; + list|ls) phpvm_list ;; + current) phpvm_current ;; + uninstall|remove) phpvm_uninstall "$@" ;; + which) phpvm_which ;; + doctor) phpvm_doctor ;; + ini) phpvm_ini ;; + deps) phpvm_deps ;; + ext) phpvm_ext "$@" ;; + composer) phpvm_composer ;; + wp-cli) phpvm_wp_cli ;; + fix-ini) phpvm_fix_ini ;; + auto) phpvm_auto ;; + hook) phpvm_hook "$@" ;; + upgrade|update) phpvm_upgrade ;; + version|-v) _ok "phpvm $PHPVM_VERSION" ;; + help|--help) phpvm_help ;; + *) _phpvm_unknown "$cmd" ;; + esac +} + +# Tests / external sourcing can set PHPVM_NO_INIT=1 to skip the source-time +# side effects below (PATH manipulation + hook registration). +if [[ -z "${PHPVM_NO_INIT:-}" ]]; then + # Auto-activate current version if set (on shell load); otherwise still make + # sure the global shim dir is on PATH. + if [[ -L "$PHPVM_CURRENT" ]]; then + _phpvm_use_path + else + _phpvm_ensure_bin_path + fi + + # Register .phpvmrc auto-switch hook if user opted in. + if [[ -f "$PHPVM_DIR/.auto-hook" ]]; then + if [[ -n "${ZSH_VERSION:-}" ]]; then + autoload -U add-zsh-hook 2>/dev/null && add-zsh-hook chpwd _phpvm_auto >/dev/null 2>&1 + elif [[ -n "${BASH_VERSION:-}" ]]; then + case "${PROMPT_COMMAND:-}" in + *_phpvm_auto*) ;; + *) PROMPT_COMMAND="_phpvm_auto -s${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;; + esac + fi + _phpvm_auto -s + fi +fi diff --git a/version.txt b/version.txt index cd99d38..d19d089 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.14.0 \ No newline at end of file +1.15.0 \ No newline at end of file diff --git a/windows/install.ps1 b/windows/install.ps1 index be18836..dbe2b6f 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -8,7 +8,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$PHPVM_VERSION = "1.14.0" +$PHPVM_VERSION = "1.15.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 22babf6..8244d3d 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -23,7 +23,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.14.0" +$PHPVM_VERSION = "1.15.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current" diff --git a/windows/src/00-header.ps1 b/windows/src/00-header.ps1 index aa2c74c..255b6e1 100644 --- a/windows/src/00-header.ps1 +++ b/windows/src/00-header.ps1 @@ -15,7 +15,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.14.0" +$PHPVM_VERSION = "1.15.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current"