From c6e74337357b29ac9e61196b27b0799739cf75f6 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:46:39 +0700 Subject: [PATCH 1/8] fix(linux): add --with-iconv prefix for macOS Homebrew build libiconv is keg-only on Homebrew and ships no pkg-config file, so configure can't detect it on the default path. Pass the explicit brew prefix on Darwin, mirroring the gmp/gettext handling. Linux keeps --with-iconv bare (resolved from glibc). devhardiyanto --- linux/phpvm.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/linux/phpvm.sh b/linux/phpvm.sh index 7cb2f5b..8fcde1d 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -493,6 +493,15 @@ phpvm_install() { 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" @@ -523,6 +532,7 @@ phpvm_install() { "--with-freetype" "$gettext_opt" "$gmp_opt" + "$iconv_opt" "--with-pgsql" "--with-pdo-pgsql" "--with-onig" From 7368759e4315cccc90d06c561612fd9490073f33 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:48:52 +0700 Subject: [PATCH 2/8] chore: bump version to 1.12.4 devhardiyanto --- linux/install.sh | 2 +- linux/phpvm.sh | 2 +- version.txt | 2 +- windows/install.ps1 | 2 +- windows/phpvm.ps1 | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/linux/install.sh b/linux/install.sh index 744adee..3188778 100644 --- a/linux/install.sh +++ b/linux/install.sh @@ -6,7 +6,7 @@ set -e -PHPVM_VERSION="1.12.3" +PHPVM_VERSION="1.12.4" 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 8fcde1d..2715cbe 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -10,7 +10,7 @@ # phpvm use 8.3.0 # ============================================================================== -PHPVM_VERSION="1.12.3" +PHPVM_VERSION="1.12.4" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_VERSIONS="$PHPVM_DIR/versions" PHPVM_CURRENT="$PHPVM_DIR/current" diff --git a/version.txt b/version.txt index c56eaaa..44fdbc3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.12.3 \ No newline at end of file +1.12.4 \ No newline at end of file diff --git a/windows/install.ps1 b/windows/install.ps1 index 574b76c..45c2eba 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -8,7 +8,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$PHPVM_VERSION = "1.12.3" +$PHPVM_VERSION = "1.12.4" $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 8a307b3..56dc6fe 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -15,7 +15,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.12.3" +$PHPVM_VERSION = "1.12.4" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current" From 7970904a1618b467ecc569f0818ed61e26bfc2e9 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:13:17 +0700 Subject: [PATCH 3/8] test(windows): cover the ext subsystem The ext block is the largest slice earmarked for the B8 split and had zero tests. Add Pester coverage for Ext-List/Loaded/Info, Get-PECLVersions, the Install-PECLExt guards, Show-ExtRuntimeNotes, Ext-Laravel preset composition and the full Invoke-Ext dispatch table. php.exe is stood in for by a global 'php' function, since the call operator resolves $info.Exe as a command name. Ext-Info is locked at source level so the PHP snippet can't regress into PowerShell interpolation again. devhardiyanto --- tests/windows/Ext.Tests.ps1 | 283 ++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 tests/windows/Ext.Tests.ps1 diff --git a/tests/windows/Ext.Tests.ps1 b/tests/windows/Ext.Tests.ps1 new file mode 100644 index 0000000..803bbd3 --- /dev/null +++ b/tests/windows/Ext.Tests.ps1 @@ -0,0 +1,283 @@ +Describe 'Ext subsystem' { + BeforeAll { + $env:PHPVM_DIR = Join-Path $TestDrive '.phpvm' + New-Item -ItemType Directory -Path $env:PHPVM_DIR -Force | Out-Null + . $PSScriptRoot/Common.ps1 + + # Fake ext/ dir: the DLLs phpvm sees when it scans a PHP build. + $script:ExtDir = Join-Path $TestDrive 'php\ext' + New-Item -ItemType Directory -Path $script:ExtDir -Force | Out-Null + foreach ($n in 'curl', 'gd', 'mbstring') { + Set-Content (Join-Path $script:ExtDir "php_$n.dll") 'stub' + } + + # `& $info.Exe -m` resolves Exe as a command name, so a function named + # 'php' stands in for the real binary. Global so it is visible from the + # dot-sourced phpvm.ps1 functions. + $script:PhpModules = @('[PHP Modules]', 'Core', 'curl', 'mbstring', '[Zend Modules]') + function global:php { + $script:PhpArgs = $args + if ($args -contains '-m') { return $script:PhpModules } + return @() + } + + $script:BuildInfo = @{ + Version = '8.3.10' + Short = '8.3' + TS = 'nts' + VS = 'vs16' + Arch = 'x64' + Exe = 'php' + Root = (Join-Path $TestDrive 'php') + ExtDir = $script:ExtDir + IniPath = (Join-Path $TestDrive 'php\php.ini') + } + } + + AfterAll { + Remove-Item Function:\php -ErrorAction SilentlyContinue + Remove-Item Env:PHPVM_DIR -ErrorAction SilentlyContinue + } + + Context 'Ext-List' { + It 'Marks a loaded DLL [ON] and an unloaded one [off]' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + + $out = Ext-List 6>&1 | Out-String + + $out | Should -Match 'curl\s+\[ON\]' + $out | Should -Match 'gd\s+\[off\]' + $out | Should -Match 'mbstring\s+\[ON\]' + } + + It 'Shows the build header and ini path' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + + $out = Ext-List 6>&1 | Out-String + + $out | Should -Match 'PHP 8\.3\.10 \[NTS / vs16 / x64\]' + $out | Should -Match 'php\.ini :' + } + + It 'Warns instead of throwing when ext/ is missing' { + $missing = $script:BuildInfo.Clone() + $missing.ExtDir = Join-Path $TestDrive 'nope\ext' + Mock -CommandName Get-PHPBuildInfo -MockWith { $missing } + + $out = Ext-List 6>&1 | Out-String + + $out | Should -Match 'ext/ directory not found' + } + } + + Context 'Ext-Loaded' { + It 'Lists modules and drops the [PHP Modules] section headers' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + + $out = Ext-Loaded 6>&1 | Out-String + + $out | Should -Match 'Loaded extensions - PHP 8\.3\.10' + $out | Should -Match 'curl' + $out | Should -Not -Match '\[PHP Modules\]' + $out | Should -Not -Match '\[Zend Modules\]' + } + } + + Context 'Ext-Info' { + It 'Emits PHP source, not PowerShell-interpolated garbage' { + # Regression lock: $r / $classes must reach PHP as literals. Asserting + # on the function body keeps this honest without a real php.exe. + $body = (Get-Command Ext-Info).ScriptBlock.ToString() + + $body | Should -Match '\$r = new ReflectionExtension' + $body | Should -Match '\$r->getName\(\)' + $body | Should -Match '\$classes' + } + + It 'Passes the extension name into extension_loaded()' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + + $null = Ext-Info 'curl' 6>&1 + + ($script:PhpArgs -join ' ') | Should -Match "extension_loaded\('curl'\)" + } + } + + Context 'Get-PECLVersions' { + It 'Parses the release index newest-first' { + Mock -CommandName Get-WebString -MockWith { + '1.2.0/10.0.1/2.5.0/' + } + + $v = Get-PECLVersions 'redis' + + $v[0] | Should -Be '10.0.1' + $v[1] | Should -Be '2.5.0' + $v[2] | Should -Be '1.2.0' + } + + It 'Returns nothing when the fetch fails' { + Mock -CommandName Get-WebString -MockWith { throw 'offline' } + + Get-PECLVersions 'nope' | Should -BeNullOrEmpty + } + } + + Context 'Install-PECLExt guard' { + It 'Stops early when the DLL is already present' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + Mock -CommandName Get-PECLVersions -MockWith { @('1.0.0') } + + $out = Install-PECLExt 'curl' 6>&1 | Out-String + + $out | Should -Match 'php_curl\.dll already installed' + Should -Invoke Get-PECLVersions -Times 0 + } + + It 'Reports an unknown extension instead of downloading' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + Mock -CommandName Get-PECLVersions -MockWith { @() } + Mock -CommandName Test-URLExists -MockWith { $true } + + $out = Install-PECLExt 'definitely-not-real' 6>&1 | Out-String + + $out | Should -Match "not found on windows\.php\.net" + Should -Invoke Test-URLExists -Times 0 + } + } + + Context 'Show-ExtRuntimeNotes' { + It 'Advises the ODBC driver for sqlsrv' { + $out = Show-ExtRuntimeNotes 'pdo_sqlsrv' 6>&1 | Out-String + $out | Should -Match 'ODBC Driver' + } + + It 'Stays silent for an ordinary extension' { + $out = Show-ExtRuntimeNotes 'redis' 6>&1 | Out-String + $out.Trim() | Should -BeNullOrEmpty + } + } + + Context 'Ext-Laravel preset composition' { + BeforeEach { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + Mock -CommandName Edit-IniExtension -MockWith { } + Mock -CommandName Install-PECLExt -MockWith { } + } + + It 'minimal enables bundled DLLs present in the build only' { + # ext/ holds curl, gd, mbstring. Of those, minimal covers curl+mbstring. + $null = Ext-Laravel 'minimal' 6>&1 + + Should -Invoke Edit-IniExtension -Times 0 -ParameterFilter { $extName -eq 'gd' } + Should -Invoke Install-PECLExt -Times 0 + } + + It 'minimal skips extensions whose DLL is absent' { + $out = Ext-Laravel 'minimal' 6>&1 | Out-String + + $out | Should -Match 'skip\s+openssl' + $out | Should -Match 'Laravel extension setup \(minimal\)' + } + + It 'minimal points at the full preset in the closing hint' { + $out = Ext-Laravel 'minimal' 6>&1 | Out-String + $out | Should -Match 'phpvm ext laravel full' + } + + It 'full adds gd and installs the redis PECL package' { + $out = Ext-Laravel 'full' 6>&1 | Out-String + + $out | Should -Match 'Laravel extension setup \(full\)' + Should -Invoke Edit-IniExtension -ParameterFilter { $extName -eq 'gd' } + Should -Invoke Install-PECLExt -ParameterFilter { $extName -eq 'redis' } + } + + It 'Reports already-ON for an extension PHP already loaded' { + $out = Ext-Laravel 'full' 6>&1 | Out-String + + $out | Should -Match 'curl\s+already ON' + Should -Invoke Edit-IniExtension -Times 0 -ParameterFilter { $extName -eq 'curl' } + } + + It 'Defaults to the full preset when no argument is given' { + $out = Ext-Laravel 6>&1 | Out-String + $out | Should -Match 'Laravel extension setup \(full\)' + } + } + + Context 'Invoke-Ext dispatch' { + BeforeEach { + Mock -CommandName Ext-List -MockWith { } + Mock -CommandName Ext-Loaded -MockWith { } + Mock -CommandName Ext-Info -MockWith { } + Mock -CommandName Ext-Laravel -MockWith { } + Mock -CommandName Edit-IniExtension -MockWith { } + Mock -CommandName Install-PECLExt -MockWith { } + Mock -CommandName Install-XDebug -MockWith { } + Mock -CommandName Show-ExtHelp -MockWith { } + } + + It 'Routes list and its ls alias' { + Invoke-Ext 'list' '' '' + Invoke-Ext 'ls' '' '' + Should -Invoke Ext-List -Times 2 + } + + It 'Routes loaded' { + Invoke-Ext 'loaded' '' '' + Should -Invoke Ext-Loaded -Times 1 + } + + It 'Routes enable/disable with the right toggle' { + Invoke-Ext 'enable' 'curl' '' + Invoke-Ext 'disable' 'curl' '' + Should -Invoke Edit-IniExtension -ParameterFilter { $enable -eq $true } + Should -Invoke Edit-IniExtension -ParameterFilter { $enable -eq $false } + } + + It 'Sends install xdebug to the dedicated installer' { + Invoke-Ext 'install' 'XDebug' '' + Should -Invoke Install-XDebug -Times 1 + Should -Invoke Install-PECLExt -Times 0 + } + + It 'Sends any other install to PECL, passing the version through' { + Invoke-Ext 'install' 'redis' '6.0.2' + Should -Invoke Install-PECLExt -ParameterFilter { $extName -eq 'redis' -and $requestedVer -eq '6.0.2' } + } + + It 'Routes info and laravel' { + Invoke-Ext 'info' 'curl' '' + Invoke-Ext 'laravel' 'minimal' '' + Should -Invoke Ext-Info -ParameterFilter { $extName -eq 'curl' } + Should -Invoke Ext-Laravel -ParameterFilter { $preset -eq 'minimal' } + } + + It 'Is case-insensitive on the subcommand' { + Invoke-Ext 'LIST' '' '' + Should -Invoke Ext-List -Times 1 + } + + It 'Falls back to help for an unknown subcommand' { + Invoke-Ext 'frobnicate' '' '' + Should -Invoke Show-ExtHelp -Times 1 + } + + It 'Errors on usage when a name-taking subcommand gets no name' { + $out = & { + Invoke-Ext 'enable' '' '' + Invoke-Ext 'disable' '' '' + Invoke-Ext 'install' '' '' + Invoke-Ext 'info' '' '' + } 6>&1 | Out-String + + $out | Should -Match 'Usage: phpvm ext enable ' + $out | Should -Match 'Usage: phpvm ext disable ' + $out | Should -Match 'Usage: phpvm ext install ' + $out | Should -Match 'Usage: phpvm ext info ' + Should -Invoke Edit-IniExtension -Times 0 + Should -Invoke Install-PECLExt -Times 0 + } + } +} From 10b17005b9a389cf7928663498e2f84363ef9829 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:15:45 +0700 Subject: [PATCH 4/8] fix(windows): stop install crashing on a single older patch Get-OlderPatch returns @(), but the output pipeline unrolls a one-element array into a bare string. Show-OlderPatchHint then hit StrictMode on .Count ("The property 'Count' cannot be found") and $older[-1] would have yielded a character instead of a version, so `phpvm install 8.3.10` threw at the very end whenever exactly one 8.3.x was already installed. Re-wrap at the call site. Linux is unaffected - _phpvm_older_patches passes newline-joined text and reads the newest with `tail -1`. devhardiyanto --- windows/phpvm.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/windows/phpvm.ps1 b/windows/phpvm.ps1 index 56dc6fe..6be4b66 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -592,7 +592,9 @@ function Get-OlderPatch ([string]$ver) { } function Show-OlderPatchHint ([string]$ver) { - $older = Get-OlderPatch $ver + # The pipeline unrolls a one-element return, and StrictMode has no .Count + # (nor a working [-1]) on the bare string that leaves behind. + $older = @(Get-OlderPatch $ver) if ($older.Count -eq 0) { return } Write-Dim "Older patch of $(($ver -split '\.')[0..1] -join '.') still installed: $($older -join ', ')" From cc62cd0a6ae7d3d96071c1ada69bcb5e4bf28a0f Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:15:46 +0700 Subject: [PATCH 5/8] test(windows): cover the version commands Second slice of the B8 safety net: Get-CurrentVersion / Remove-Junction against a real junction, the Invoke-Use guards, Invoke-List, Invoke-Current, Invoke-Uninstall, Invoke-Which, Invoke-Ini and Show-OlderPatchHint - the last of which is what surfaced the single-older-patch crash. Invoke-Use's happy path is deliberately left out: it rewrites the User PATH and broadcasts WM_SETTINGCHANGE, which is not something a test run should do to the machine. Only the pre-PATH guards are asserted. devhardiyanto --- tests/windows/VersionCommands.Tests.ps1 | 256 ++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 tests/windows/VersionCommands.Tests.ps1 diff --git a/tests/windows/VersionCommands.Tests.ps1 b/tests/windows/VersionCommands.Tests.ps1 new file mode 100644 index 0000000..cab7832 --- /dev/null +++ b/tests/windows/VersionCommands.Tests.ps1 @@ -0,0 +1,256 @@ +Describe 'Version commands' { + BeforeAll { + $env:PHPVM_DIR = Join-Path $TestDrive '.phpvm' + New-Item -ItemType Directory -Path $env:PHPVM_DIR -Force | Out-Null + . $PSScriptRoot/Common.ps1 + + # Fake installs: a dir per version, php.exe stubbed as a plain file so the + # "missing php.exe" guard can be exercised separately. + function New-FakeVersion ([string]$ver, [switch]$NoExe) { + $dir = Join-Path $VERSIONS_DIR $ver + New-Item -ItemType Directory -Path $dir -Force | Out-Null + if (-not $NoExe) { Set-Content (Join-Path $dir 'php.exe') 'stub' } + return $dir + } + + function New-CurrentJunction ([string]$ver) { + Remove-Junction $CURRENT_LINK + cmd /c mklink /J "$CURRENT_LINK" "$VERSIONS_DIR\$ver" | Out-Null + } + } + + AfterAll { + Remove-Junction $CURRENT_LINK + Remove-Item Env:PHPVM_DIR -ErrorAction SilentlyContinue + } + + Context 'Get-CurrentVersion / Remove-Junction' { + It 'Returns $null when no current junction exists' { + Remove-Junction $CURRENT_LINK + Get-CurrentVersion | Should -BeNullOrEmpty + } + + It 'Reads the version off the junction target' { + New-FakeVersion '8.3.10' | Out-Null + New-CurrentJunction '8.3.10' + + Get-CurrentVersion | Should -Be '8.3.10' + } + + It 'Drops the junction without deleting the version it points at' { + New-FakeVersion '8.3.10' | Out-Null + New-CurrentJunction '8.3.10' + + Remove-Junction $CURRENT_LINK + + Test-Path $CURRENT_LINK | Should -BeFalse + Test-Path "$VERSIONS_DIR\8.3.10\php.exe" | Should -BeTrue + } + } + + Context 'Invoke-Use guards' { + # The happy path rewrites the *User* PATH and broadcasts WM_SETTINGCHANGE, + # so only the pre-PATH guards are exercised here. + It 'Prints usage when no version is given' { + $out = Invoke-Use '' 6>&1 | Out-String + $out | Should -Match 'Usage: phpvm use ' + } + + It 'Refuses a version that is not installed' { + $out = Invoke-Use '5.4.0' 6>&1 | Out-String + $out | Should -Match 'PHP 5\.4\.0 is not installed' + $out | Should -Match 'phpvm install 5\.4\.0' + } + + It 'Refuses an install directory with no php.exe' { + New-FakeVersion '7.4.33' -NoExe | Out-Null + + $out = Invoke-Use '7.4.33' 6>&1 | Out-String + + $out | Should -Match 'Invalid PHP 7\.4\.33 install' + $out | Should -Match 'missing' + } + + It 'Leaves the current junction untouched when a guard trips' { + New-FakeVersion '8.3.10' | Out-Null + New-CurrentJunction '8.3.10' + + $null = Invoke-Use '5.4.0' 6>&1 + + Get-CurrentVersion | Should -Be '8.3.10' + } + } + + Context 'Invoke-List' { + It 'Says nothing is installed on an empty versions dir' { + Remove-Item "$VERSIONS_DIR\*" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Junction $CURRENT_LINK + + $out = Invoke-List 6>&1 | Out-String + + $out | Should -Match 'No PHP versions installed' + } + + It 'Lists installed versions sorted by name' { + New-FakeVersion '8.1.2' | Out-Null + New-FakeVersion '8.3.10' | Out-Null + + $out = Invoke-List 6>&1 | Out-String + + $out | Should -Match 'Installed versions:' + $out.IndexOf('8.1.2') | Should -BeLessThan $out.IndexOf('8.3.10') + } + + It 'Marks the active version with an arrow' { + New-FakeVersion '8.1.2' | Out-Null + New-FakeVersion '8.3.10' | Out-Null + New-CurrentJunction '8.3.10' + + $out = Invoke-List 6>&1 | Out-String + + $out | Should -Match '->\s+8\.3\.10\s+\(active\)' + $out | Should -Not -Match '->\s+8\.1\.2' + } + } + + Context 'Invoke-Current' { + It 'Warns when nothing is active' { + Mock -CommandName Get-CurrentVersion -MockWith { $null } + + $out = Invoke-Current 6>&1 | Out-String + + $out | Should -Match 'No PHP version active' + } + + It 'Reports the active version' { + Mock -CommandName Get-CurrentVersion -MockWith { '8.3.10' } + + $out = Invoke-Current 6>&1 | Out-String + + $out | Should -Match 'Active: 8\.3\.10' + } + } + + Context 'Invoke-Uninstall' { + It 'Prints usage when no version is given' { + $out = Invoke-Uninstall '' 6>&1 | Out-String + $out | Should -Match 'Usage: phpvm uninstall ' + } + + It 'Refuses a version that is not installed' { + $out = Invoke-Uninstall '5.4.0' 6>&1 | Out-String + $out | Should -Match 'PHP 5\.4\.0 is not installed' + } + + It 'Refuses to remove the active version' { + New-FakeVersion '8.3.10' | Out-Null + New-CurrentJunction '8.3.10' + + $out = Invoke-Uninstall '8.3.10' 6>&1 | Out-String + + $out | Should -Match 'Cannot uninstall the active version' + Test-Path "$VERSIONS_DIR\8.3.10" | Should -BeTrue + } + + It 'Removes an inactive version from disk' { + New-FakeVersion '8.3.10' | Out-Null + New-FakeVersion '8.1.2' | Out-Null + New-CurrentJunction '8.3.10' + + $out = Invoke-Uninstall '8.1.2' 6>&1 | Out-String + + $out | Should -Match 'PHP 8\.1\.2 has been removed' + Test-Path "$VERSIONS_DIR\8.1.2" | Should -BeFalse + } + } + + Context 'Invoke-Which' { + BeforeAll { + $script:ShimDir = Join-Path $TestDrive 'shim' + New-Item -ItemType Directory -Path $script:ShimDir -Force | Out-Null + Set-Content (Join-Path $script:ShimDir 'php.cmd') '@echo off' + } + + BeforeEach { + $script:SavedPath = $env:PATH + # The ext tests stand a global 'php' function up; it would shadow PATH. + Remove-Item Function:\php -ErrorAction SilentlyContinue + } + + AfterEach { $env:PATH = $script:SavedPath } + + It 'Reports the resolved php on PATH' { + $env:PATH = "$script:ShimDir;$env:PATH" + + $out = Invoke-Which 6>&1 | Out-String + + $out | Should -Match 'php\.cmd' + } + + It 'Warns when php is not on PATH' { + $env:PATH = Join-Path $TestDrive 'empty' + + $out = Invoke-Which 6>&1 | Out-String + + $out | Should -Match 'php not found in PATH' + } + } + + Context 'Invoke-Ini' { + It 'Errors when no version is active' { + Mock -CommandName Get-CurrentVersion -MockWith { $null } + Mock -CommandName Start-Process -MockWith { } + + $out = Invoke-Ini 6>&1 | Out-String + + $out | Should -Match 'No active PHP version' + Should -Invoke Start-Process -Times 0 + } + + It 'Errors when the active version has no php.ini' { + New-FakeVersion '8.3.10' | Out-Null + Remove-Item "$VERSIONS_DIR\8.3.10\php.ini" -Force -ErrorAction SilentlyContinue + Mock -CommandName Get-CurrentVersion -MockWith { '8.3.10' } + Mock -CommandName Start-Process -MockWith { } + + $out = Invoke-Ini 6>&1 | Out-String + + $out | Should -Match 'php\.ini not found' + Should -Invoke Start-Process -Times 0 + } + + It 'Opens the php.ini of the active version' { + New-FakeVersion '8.3.10' | Out-Null + Set-Content "$VERSIONS_DIR\8.3.10\php.ini" '; stub' + Mock -CommandName Get-CurrentVersion -MockWith { '8.3.10' } + Mock -CommandName Start-Process -MockWith { } + + $out = Invoke-Ini 6>&1 | Out-String + + $out | Should -Match 'Opening .*8\.3\.10\\php\.ini' + Should -Invoke Start-Process -ParameterFilter { $FilePath -eq 'notepad' } + } + } + + Context 'Show-OlderPatchHint' { + It 'Names the older patches of the same minor line' { + New-FakeVersion '8.3.1' | Out-Null + New-FakeVersion '8.3.10' | Out-Null + + $out = Show-OlderPatchHint '8.3.10' 6>&1 | Out-String + + $out | Should -Match 'Older patch of 8\.3 still installed: 8\.3\.1' + $out | Should -Match 'phpvm uninstall 8\.3\.1' + } + + It 'Stays silent when nothing older is installed' { + Remove-Item "$VERSIONS_DIR\*" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Junction $CURRENT_LINK + New-FakeVersion '8.3.10' | Out-Null + + $out = Show-OlderPatchHint '8.3.10' 6>&1 | Out-String + + $out.Trim() | Should -BeNullOrEmpty + } + } +} From 5081e38ccc3073961cf33961a550dd8b3993d1a4 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:18:09 +0700 Subject: [PATCH 6/8] test(windows): cover tools and maintenance commands Third slice of the B8 safety net: Invoke-Composer (shim guard, openssl pre-step, download failure), Invoke-Cacert (status/update/usage), Invoke-FixIni (all four extension_dir branches), the phpvm hook install/uninstall/status cycle against a redirected $PROFILE, and the two offline branches of Invoke-Upgrade. Nothing here touches the network or the real $PROFILE. The FixIni "already correct" case is written as a convergence test, since the first pass legitimately rewrites a CRLF ini before settling. devhardiyanto --- tests/windows/ToolsMaint.Tests.ps1 | 336 +++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 tests/windows/ToolsMaint.Tests.ps1 diff --git a/tests/windows/ToolsMaint.Tests.ps1 b/tests/windows/ToolsMaint.Tests.ps1 new file mode 100644 index 0000000..9b6f087 --- /dev/null +++ b/tests/windows/ToolsMaint.Tests.ps1 @@ -0,0 +1,336 @@ +Describe 'Tools and maintenance commands' { + BeforeAll { + $env:PHPVM_DIR = Join-Path $TestDrive '.phpvm' + New-Item -ItemType Directory -Path $env:PHPVM_DIR -Force | Out-Null + . $PSScriptRoot/Common.ps1 + + New-Item -ItemType Directory -Path $VERSIONS_DIR -Force | Out-Null + New-Item -ItemType Directory -Path $PHPVM_BIN -Force | Out-Null + + # See Ext.Tests.ps1: `& $info.Exe` resolves Exe as a command name. + $script:PhpModules = @('Core', 'curl', 'openssl') + function global:php { if ($args -contains '-m') { return $script:PhpModules }; return @() } + + $script:BuildInfo = @{ + Version = '8.3.10' + Short = '8.3' + TS = 'nts' + VS = 'vs16' + Arch = 'x64' + Exe = 'php' + Root = (Join-Path $TestDrive 'php') + ExtDir = (Join-Path $TestDrive 'php\ext') + IniPath = (Join-Path $TestDrive 'php\php.ini') + } + } + + AfterAll { + Remove-Item Function:\php -ErrorAction SilentlyContinue + Remove-Item Env:PHPVM_DIR -ErrorAction SilentlyContinue + } + + Context 'Invoke-Composer' { + AfterEach { + Remove-Item "$PHPVM_BIN\composer.bat" -Force -ErrorAction SilentlyContinue + } + + It 'Stops at the shim when Composer is already installed' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + Mock -CommandName Invoke-WebRequest -MockWith { } + Set-Content "$PHPVM_BIN\composer.bat" '@echo off' + + $out = Invoke-Composer 6>&1 | Out-String + + $out | Should -Match 'Composer already installed' + $out | Should -Match 'follows your active PHP version' + Should -Invoke Invoke-WebRequest -Times 0 + } + + It 'Leaves openssl alone when PHP already loads it' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + Mock -CommandName Edit-IniExtension -MockWith { } + Mock -CommandName Invoke-WebRequest -MockWith { throw 'no network in tests' } + + $null = Invoke-Composer 6>&1 + + Should -Invoke Edit-IniExtension -Times 0 + } + + It 'Enables openssl first when it is missing' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + Mock -CommandName Edit-IniExtension -MockWith { } + Mock -CommandName Invoke-WebRequest -MockWith { throw 'no network in tests' } + $script:PhpModules = @('Core', 'curl') + + $out = Invoke-Composer 6>&1 | Out-String + + $out | Should -Match 'Enabling openssl extension' + Should -Invoke Edit-IniExtension -ParameterFilter { $extName -eq 'openssl' -and $enable -eq $true } + + $script:PhpModules = @('Core', 'curl', 'openssl') + } + + It 'Reports a failed download instead of throwing' { + Mock -CommandName Get-PHPBuildInfo -MockWith { $script:BuildInfo } + Mock -CommandName Invoke-WebRequest -MockWith { throw 'connection reset' } + + $out = Invoke-Composer 6>&1 | Out-String + + $out | Should -Match 'Download failed' + } + } + + Context 'Invoke-Cacert' { + AfterEach { + Remove-Item $PHPVM_CACERT -Force -ErrorAction SilentlyContinue + } + + It 'Warns when no bundle has been fetched yet' { + $out = Invoke-Cacert 'status' 6>&1 | Out-String + $out | Should -Match 'No CA bundle yet' + } + + It 'Treats an empty subcommand as status' { + $out = Invoke-Cacert '' 6>&1 | Out-String + $out | Should -Match 'No CA bundle yet' + } + + It 'Reports the bundle path and its age' { + Set-Content $PHPVM_CACERT '-----BEGIN CERTIFICATE-----' + + $out = Invoke-Cacert 'status' 6>&1 | Out-String + + $out | Should -Match 'CA bundle:' + $out | Should -Match 'updated 0 day\(s\) ago' + $out | Should -Match 'phpvm cacert update' + } + + It 'Rewires the active php.ini on update' { + New-Item -ItemType Directory -Path "$VERSIONS_DIR\8.3.10" -Force | Out-Null + Set-Content "$VERSIONS_DIR\8.3.10\php.ini" ';curl.cainfo =' + Mock -CommandName Get-CABundle -MockWith { $PHPVM_CACERT } + Mock -CommandName Get-CurrentVersion -MockWith { '8.3.10' } + + $out = Invoke-Cacert 'update' 6>&1 | Out-String + + $out | Should -Match 'Active php\.ini points at the refreshed bundle' + Should -Invoke Get-CABundle -ParameterFilter { $Force -eq $true } + } + + It 'Bails out quietly when the bundle cannot be fetched' { + Mock -CommandName Get-CABundle -MockWith { $null } + Mock -CommandName Get-CurrentVersion -MockWith { '8.3.10' } + + $null = Invoke-Cacert 'update' 6>&1 + + Should -Invoke Get-CurrentVersion -Times 0 + } + + It 'Prints usage for an unknown subcommand' { + $out = Invoke-Cacert 'frobnicate' 6>&1 | Out-String + $out | Should -Match 'Usage: phpvm cacert \[status\|update\]' + } + } + + Context 'Invoke-FixIni' { + BeforeEach { + Mock -CommandName Get-CurrentVersion -MockWith { '8.3.10' } + # Keep the CA-bundle repair out of the network. + Mock -CommandName Get-CABundle -MockWith { $null } + New-Item -ItemType Directory -Path "$VERSIONS_DIR\8.3.10" -Force | Out-Null + } + + It 'Errors when no version is active' { + Mock -CommandName Get-CurrentVersion -MockWith { $null } + + $out = Invoke-FixIni 6>&1 | Out-String + + $out | Should -Match 'No active PHP version' + } + + It 'Errors when php.ini is missing' { + Remove-Item "$VERSIONS_DIR\8.3.10\php.ini" -Force -ErrorAction SilentlyContinue + + $out = Invoke-FixIni 6>&1 | Out-String + + $out | Should -Match 'php\.ini not found' + } + + It 'Repoints a stale extension_dir at the active version' { + Set-Content "$VERSIONS_DIR\8.3.10\php.ini" "extension_dir = `"C:\xampp\php\ext`"`nmemory_limit = 128M" + + $out = Invoke-FixIni 6>&1 | Out-String + + $out | Should -Match 'Fixed extension_dir' + $ini = Get-Content "$VERSIONS_DIR\8.3.10\php.ini" -Raw + $ini | Should -Match ([regex]::Escape("extension_dir = `"$VERSIONS_DIR\8.3.10\ext`"")) + $ini | Should -Match 'memory_limit = 128M' + } + + It 'Uncomments a commented-out extension_dir' { + Set-Content "$VERSIONS_DIR\8.3.10\php.ini" ';extension_dir = "ext"' + + $null = Invoke-FixIni 6>&1 + + $ini = Get-Content "$VERSIONS_DIR\8.3.10\php.ini" -Raw + $ini | Should -Not -Match '^;extension_dir' + $ini | Should -Match ([regex]::Escape("$VERSIONS_DIR\8.3.10\ext")) + } + + It 'Appends extension_dir when the directive is absent entirely' { + Set-Content "$VERSIONS_DIR\8.3.10\php.ini" 'memory_limit = 128M' + + $out = Invoke-FixIni 6>&1 | Out-String + + $out | Should -Match 'Added extension_dir' + (Get-Content "$VERSIONS_DIR\8.3.10\php.ini" -Raw) | + Should -Match ([regex]::Escape("extension_dir = `"$VERSIONS_DIR\8.3.10\ext`"")) + } + + It 'Settles - a second run reports nothing left to change' { + # First pass still rewrites: the (?m)$ match swallows the CR of a + # CRLF ini, so the line differs by its line ending even when the + # path is already right. It converges on the second pass. + Set-Content "$VERSIONS_DIR\8.3.10\php.ini" "extension_dir = `"$VERSIONS_DIR\8.3.10\ext`"" + $null = Invoke-FixIni 6>&1 + + $out = Invoke-FixIni 6>&1 | Out-String + + $out | Should -Match 'already correct or not found' + $out | Should -Not -Match 'Added extension_dir' + } + } + + Context 'phpvm hook' { + BeforeAll { + $script:RealProfile = $PROFILE + $script:FakeProfile = Join-Path $TestDrive 'profile\Microsoft.PowerShell_profile.ps1' + New-Item -ItemType Directory -Path (Split-Path $script:FakeProfile) -Force | Out-Null + $global:PROFILE = [PSCustomObject]@{ CurrentUserCurrentHost = $script:FakeProfile } + } + + AfterAll { $global:PROFILE = $script:RealProfile } + + BeforeEach { + Remove-Item $script:FakeProfile -Force -ErrorAction SilentlyContinue + } + + It 'Reports "not installed" when there is no $PROFILE' { + $out = Show-PHPVMHookStatus 6>&1 | Out-String + $out | Should -Match 'hook not installed' + } + + It 'Creates the profile and writes the hook' { + $out = Install-PHPVMHook 6>&1 | Out-String + + $out | Should -Match 'Installed hook ->' + (Get-Content $script:FakeProfile -Raw) | Should -Match 'phpvm-auto-hook' + } + + It 'Emits a prompt hook that calls phpvm auto -Silent' { + $snippet = Get-PHPVMHookSnippet + + $snippet | Should -Match 'function global:prompt' + $snippet | Should -Match 'phpvm auto -Silent' + # The PowerShell vars must survive as literals, not be interpolated + # while the snippet is being built. + $snippet | Should -Match '\$global:__phpvm_prev_prompt' + } + + It 'Is idempotent - a second enable warns instead of duplicating' { + $null = Install-PHPVMHook 6>&1 + $out = Install-PHPVMHook 6>&1 | Out-String + + $out | Should -Match 'already installed' + $hits = ([regex]::Matches((Get-Content $script:FakeProfile -Raw), 'phpvm-auto-hook')).Count + $hits | Should -Be 1 + } + + It 'Reports installed once the hook is in place' { + $null = Install-PHPVMHook 6>&1 + + $out = Show-PHPVMHookStatus 6>&1 | Out-String + + $out | Should -Match 'Hook installed in' + } + + It 'Strips the hook back out and keeps surrounding content' { + Set-Content $script:FakeProfile "Set-Alias ll Get-ChildItem" + $null = Install-PHPVMHook 6>&1 + + $out = Uninstall-PHPVMHook 6>&1 | Out-String + + $out | Should -Match 'Removed hook from' + $content = Get-Content $script:FakeProfile -Raw + $content | Should -Not -Match 'phpvm-auto-hook' + $content | Should -Match 'Set-Alias ll Get-ChildItem' + } + + It 'Warns when disabling a hook that was never installed' { + Set-Content $script:FakeProfile "Set-Alias ll Get-ChildItem" + + $out = Uninstall-PHPVMHook 6>&1 | Out-String + + $out | Should -Match 'hook not found' + } + + It 'Warns when disabling with no $PROFILE at all' { + $out = Uninstall-PHPVMHook 6>&1 | Out-String + $out | Should -Match 'No \$PROFILE found' + } + + Context 'Invoke-Hook dispatch' { + BeforeEach { + Mock -CommandName Install-PHPVMHook -MockWith { } + Mock -CommandName Uninstall-PHPVMHook -MockWith { } + Mock -CommandName Show-PHPVMHookStatus -MockWith { } + } + + It 'Routes enable / disable / status' { + Invoke-Hook 'enable' + Invoke-Hook 'disable' + Invoke-Hook 'status' + + Should -Invoke Install-PHPVMHook -Times 1 + Should -Invoke Uninstall-PHPVMHook -Times 1 + Should -Invoke Show-PHPVMHookStatus -Times 1 + } + + It 'Is case-insensitive' { + Invoke-Hook 'ENABLE' + Should -Invoke Install-PHPVMHook -Times 1 + } + + It 'Shows usage for anything else' { + $out = Invoke-Hook 'frobnicate' 6>&1 | Out-String + + $out | Should -Match 'phpvm hook enable' + $out | Should -Match 'phpvm hook disable' + $out | Should -Match 'phpvm hook status' + Should -Invoke Install-PHPVMHook -Times 0 + } + } + } + + Context 'Invoke-Upgrade' { + It 'Reports up to date when the remote version is not newer' { + Mock -CommandName Get-WebString -MockWith { '0.0.1' } + Mock -CommandName Invoke-WebRequest -MockWith { } + + $out = Invoke-Upgrade 6>&1 | Out-String + + $out | Should -Match 'Already up to date' + Should -Invoke Invoke-WebRequest -Times 0 + } + + It 'Reports an unreachable GitHub instead of throwing' { + Mock -CommandName Get-WebString -MockWith { throw 'dns failure' } + Mock -CommandName Invoke-WebRequest -MockWith { } + + $out = Invoke-Upgrade 6>&1 | Out-String + + $out | Should -Match 'Could not reach GitHub' + Should -Invoke Invoke-WebRequest -Times 0 + } + } +} From ff78d5b2e428d5c9a1106790be58c4d0532814a8 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:43:24 +0700 Subject: [PATCH 7/8] refactor(windows): split phpvm.ps1 into windows/src modules B8. phpvm.ps1 had grown to 1797 lines in one file. Carve it into 15 domain modules under windows/src/ and concatenate them back at build time, so distribution is untouched - the installer and `phpvm upgrade` still fetch a single windows/phpvm.ps1, which stays committed and is now marked GENERATED. Filename prefixes fix the concat order, and that order is load-bearing: 00-header.ps1 opens with param(), which must be the first statement, and 99-entry.ps1 closes with the dispatch switch, which must see every function. No source line was added, dropped or reworded - only moved. CI gains a drift check (./build.ps1 -Check) that fails when phpvm.ps1 and the modules disagree, mirrored by Build.Tests.ps1 for local runs. PSScriptAnalyzer stops recursing into windows/src: a module read alone is a fragment, and linting it that way flags every shared constant as unused. The drift gate is what ties the linted file back to the source. devhardiyanto --- .github/workflows/ci.yml | 13 +- README.md | 31 +++ build.ps1 | 69 ++++++ tests/windows/Build.Tests.ps1 | 54 ++++ windows/phpvm.ps1 | 222 +++++++++-------- windows/src/00-header.ps1 | 29 +++ windows/src/10-output.ps1 | 74 ++++++ windows/src/20-phpinfo.ps1 | 147 +++++++++++ windows/src/30-net.ps1 | 136 ++++++++++ windows/src/35-cacert.ps1 | 52 ++++ windows/src/40-install.ps1 | 159 ++++++++++++ windows/src/45-version.ps1 | 102 ++++++++ windows/src/50-auto.ps1 | 93 +++++++ windows/src/55-hook.ps1 | 81 ++++++ windows/src/60-ext.ps1 | 454 ++++++++++++++++++++++++++++++++++ windows/src/70-composer.ps1 | 77 ++++++ windows/src/72-wpcli.ps1 | 54 ++++ windows/src/80-maint.ps1 | 196 +++++++++++++++ windows/src/90-help.ps1 | 97 ++++++++ windows/src/99-entry.ps1 | 32 +++ 20 files changed, 2071 insertions(+), 101 deletions(-) create mode 100644 build.ps1 create mode 100644 tests/windows/Build.Tests.ps1 create mode 100644 windows/src/00-header.ps1 create mode 100644 windows/src/10-output.ps1 create mode 100644 windows/src/20-phpinfo.ps1 create mode 100644 windows/src/30-net.ps1 create mode 100644 windows/src/35-cacert.ps1 create mode 100644 windows/src/40-install.ps1 create mode 100644 windows/src/45-version.ps1 create mode 100644 windows/src/50-auto.ps1 create mode 100644 windows/src/55-hook.ps1 create mode 100644 windows/src/60-ext.ps1 create mode 100644 windows/src/70-composer.ps1 create mode 100644 windows/src/72-wpcli.ps1 create mode 100644 windows/src/80-maint.ps1 create mode 100644 windows/src/90-help.ps1 create mode 100644 windows/src/99-entry.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c075a8e..7d238bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,21 @@ jobs: Install-Module -Name Pester -MinimumVersion 5.5.0 -Scope CurrentUser -Force -SkipPublisherCheck Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force + - name: Verify phpvm.ps1 matches windows/src (drift check) + shell: pwsh + run: ./build.ps1 -Check + - name: PSScriptAnalyzer shell: pwsh run: | - $results = Invoke-ScriptAnalyzer -Path ./windows -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 + # Deliberately not -Recurse: windows/src/*.ps1 are fragments, not + # standalone scripts, so linting them alone flags every constant as + # unused and every param() as unreferenced. The drift check above + # guarantees the generated file is what those modules say. + $files = @(Get-ChildItem ./windows -Filter *.ps1) + @(Get-Item ./build.ps1) + $results = $files | ForEach-Object { + Invoke-ScriptAnalyzer -Path $_.FullName -Settings ./PSScriptAnalyzerSettings.psd1 + } if ($results) { $results | Format-Table -AutoSize throw "PSScriptAnalyzer found $($results.Count) issue(s)." diff --git a/README.md b/README.md index cd88e36..b84e477 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,37 @@ 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: + +```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. + +`linux/phpvm.sh` is hand-written and not part of the build. + +Tests: + +```powershell +Invoke-Pester -Configuration (New-PesterConfiguration -Hashtable (Import-PowerShellDataFile ./tests/PesterConfiguration.psd1)) +``` + +```bash +bats tests/linux/ +``` + +--- + ## License MIT diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..7729007 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Concatenate windows/src/*.ps1 into the shipped windows/phpvm.ps1. + +.DESCRIPTION + phpvm is distributed as a single file: the installer downloads + windows/phpvm.ps1 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. + The order is load-bearing: 00-header.ps1 opens with param(), which must be + the first statement in the script, and 99-entry.ps1 closes with the command + dispatch, which must see every function already defined. + +.PARAMETER Check + Compare instead of write. Exits non-zero when windows/phpvm.ps1 has drifted + from the modules - this is what CI gates on. + +.EXAMPLE + pwsh ./build.ps1 + pwsh ./build.ps1 -Check +#> +[CmdletBinding()] +param([switch]$Check) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$root = $PSScriptRoot +$srcDir = Join-Path $root 'windows\src' +$outFile = Join-Path $root 'windows\phpvm.ps1' + +$banner = @' +# ============================================================================== +# GENERATED FILE - DO NOT EDIT +# Built from windows/src/*.ps1 (concatenated in filename order). +# Edit a module there, then run: pwsh ./build.ps1 +# CI fails the drift check if this file and the modules disagree. +# ============================================================================== +'@ + +$modules = Get-ChildItem $srcDir -Filter '*.ps1' | Sort-Object Name +if (-not $modules) { throw "No modules found in $srcDir" } + +# LF throughout: the repo stores LF and CI greps would trip over stray CRs. +$parts = foreach ($m in $modules) { + $text = [System.IO.File]::ReadAllText($m.FullName) -replace "`r`n", "`n" + "# --- src/$($m.Name) " + ('-' * [Math]::Max(1, 74 - $m.Name.Length)) + "`n" + $text.TrimEnd() + "`n" +} +$built = $banner.Replace("`r`n", "`n") + "`n`n" + ($parts -join "`n") + +if ($Check) { + if (-not (Test-Path $outFile)) { throw "Missing $outFile - run ./build.ps1" } + $current = [System.IO.File]::ReadAllText($outFile) -replace "`r`n", "`n" + if ($current -ceq $built) { + Write-Host "OK: windows/phpvm.ps1 matches windows/src/*.ps1 ($($modules.Count) modules)." + exit 0 + } + + Write-Host "DRIFT: windows/phpvm.ps1 does not match windows/src/*.ps1." -ForegroundColor Red + Write-Host "Rebuild with: pwsh ./build.ps1" -ForegroundColor Yellow + $diff = Compare-Object ($current -split "`n") ($built -split "`n") + $diff | Select-Object -First 20 | Format-Table -AutoSize | Out-String | Write-Host + exit 1 +} + +[System.IO.File]::WriteAllText($outFile, $built, (New-Object System.Text.UTF8Encoding $false)) +Write-Host "Built windows/phpvm.ps1 from $($modules.Count) modules ($(($built -split "`n").Count) lines)." diff --git a/tests/windows/Build.Tests.ps1 b/tests/windows/Build.Tests.ps1 new file mode 100644 index 0000000..e9694a4 --- /dev/null +++ b/tests/windows/Build.Tests.ps1 @@ -0,0 +1,54 @@ +Describe 'build.ps1' { + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:BuildPs1 = Join-Path $script:RepoRoot 'build.ps1' + $script:SrcDir = Join-Path $script:RepoRoot 'windows\src' + $script:Generated = Join-Path $script:RepoRoot 'windows\phpvm.ps1' + } + + It 'Ships every module the generated file was built from' { + (Get-ChildItem $script:SrcDir -Filter '*.ps1').Count | Should -BeGreaterThan 0 + } + + It 'Keeps windows/phpvm.ps1 in sync with windows/src (drift check)' { + # Same gate CI runs. Failing here means: pwsh ./build.ps1 + $out = & $script:BuildPs1 -Check 2>&1 | Out-String + + $LASTEXITCODE | Should -Be 0 -Because "phpvm.ps1 has drifted:`n$out" + } + + It 'Marks the generated file as generated' { + $head = (Get-Content $script:Generated -TotalCount 10) -join "`n" + + $head | Should -Match 'GENERATED FILE - DO NOT EDIT' + $head | Should -Match 'build\.ps1' + } + + It 'Puts param() before any executable statement' { + # PowerShell rejects the script outright otherwise, so the concat order + # has to keep 00-header.ps1 first. + $lines = Get-Content $script:Generated + $firstCode = ($lines | Where-Object { $_.Trim() -and $_.TrimStart() -notlike '#*' } | Select-Object -First 1) + + $firstCode.TrimStart() | Should -BeLike 'param(*' + } + + It 'Keeps the command dispatch last' { + $lines = Get-Content $script:Generated + $dispatch = ($lines | Select-String -SimpleMatch 'if (-not $env:PHPVM_NO_ENTRY)').LineNumber + $lastFunc = ($lines | Select-String -Pattern '^function ' | Select-Object -Last 1).LineNumber + + $dispatch | Should -BeGreaterThan $lastFunc + } + + It 'Declares the version exactly once' { + ($lines = Get-Content $script:Generated | Select-String -Pattern '^\$PHPVM_VERSION').Count | + Should -Be 1 + } + + It 'Carries no CR - the repo stores LF' { + $raw = [System.IO.File]::ReadAllText($script:Generated) + # A dev checkout may be CRLF; only fail on a lone CR the build introduced. + ($raw -replace "`r`n", '') | Should -Not -Match "`r" + } +} diff --git a/windows/phpvm.ps1 b/windows/phpvm.ps1 index 6be4b66..4cc63ea 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -1,4 +1,12 @@ # ============================================================================== +# GENERATED FILE - DO NOT EDIT +# Built from windows/src/*.ps1 (concatenated in filename order). +# Edit a module there, then run: pwsh ./build.ps1 +# CI fails the drift check if this file and the modules disagree. +# ============================================================================== + +# --- src/00-header.ps1 ------------------------------------------------------------- +# ============================================================================== # phpvm.ps1 - PHP Version Manager for Windows # Compatible with: CMD (via phpvm.cmd shim) and PowerShell # Repo: https://github.com/devhardiyanto/phpvm @@ -28,6 +36,7 @@ $PHPVM_UPDATE_URL = "https://raw.githubusercontent.com/devhardiyanto/phpvm/mai $PHPVM_LAST_CHECK = "$PHPVM_DIR\.last_update_check" $PHPVM_CHECK_INTERVAL = 3600 # 1 hour in seconds +# --- src/10-output.ps1 ------------------------------------------------------------- function Check-PHPVMUpdate { if ($env:CI -or $env:PHPVM_NO_UPDATE_CHECK) { return } @@ -103,6 +112,7 @@ function Initialize-PHPVM { } } +# --- src/20-phpinfo.ps1 ------------------------------------------------------------ # -- PHP build metadata -------------------------------------------------------- # Some Windows PHP builds leak warnings into stdout; strip them. function Invoke-PHP ([string]$exe, [string]$code) { @@ -251,6 +261,7 @@ function Remove-Junction ([string]$path) { } } +# --- src/30-net.ps1 ---------------------------------------------------------------- # -- Download helper ----------------------------------------------------------- # Progress is only worth drawing for the PHP zips (tens of MB); the Xdebug DLL # and ext zips are small enough that a bar would just flicker. @@ -348,6 +359,47 @@ function Unblock-PHPVMPath ([string]$path) { } } +# Look up the expected SHA-256 for a PHP zip on windows.php.net. +# Returns lowercase hex digest, or $null if no checksum is published. +function Get-PHPZipHash ([string]$zipUrl) { + $sumUrl = ($zipUrl -replace '/[^/]+\.zip$', '/') + 'sha256sum.txt' + $zipName = Split-Path $zipUrl -Leaf + try { $sums = Get-WebString $sumUrl 10 } catch { return $null } + + foreach ($line in $sums -split "`r?`n") { + if ($line -match "^([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($zipName))\s*$") { + return $Matches[1].ToLower() + } + } + return $null +} + +# Fetch a sibling .sha256 file (xdebug.org convention) and return its digest. +function Get-XDebugHash ([string]$dllUrl) { + try { $content = Get-WebString "$dllUrl.sha256" 10 } catch { return $null } + if ($content -match '([0-9a-fA-F]{64})') { return $Matches[1].ToLower() } + return $null +} + +function Test-URLExists ([string]$url) { + $ProgressPreference = "SilentlyContinue" + # HEAD via Invoke-WebRequest follows 30x redirects (windows.php.net -> downloads.php.net). + try { + $r = Invoke-WebRequest -Uri $url -Method Head -MaximumRedirection 5 ` + -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) + } catch { + # Some mirrors reject HEAD (405) -- fall back to a 1-byte ranged GET. + try { + $r = Invoke-WebRequest -Uri $url -Method Get -MaximumRedirection 5 ` + -UseBasicParsing -TimeoutSec 5 ` + -Headers @{ Range = "bytes=0-0" } -ErrorAction Stop + return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) + } catch { return $false } + } +} + +# --- src/35-cacert.ps1 ------------------------------------------------------------- # -- CA bundle (curl.cainfo / openssl.cafile) ---------------------------------- # Windows PHP builds ship no CA bundle, so every HTTPS request from PHP fails # with cURL error 60 until one is configured. One shared bundle in $PHPVM_DIR @@ -401,46 +453,7 @@ function Update-IniCACert ([string]$iniPath, [string]$bundlePath) { return $true } -# Look up the expected SHA-256 for a PHP zip on windows.php.net. -# Returns lowercase hex digest, or $null if no checksum is published. -function Get-PHPZipHash ([string]$zipUrl) { - $sumUrl = ($zipUrl -replace '/[^/]+\.zip$', '/') + 'sha256sum.txt' - $zipName = Split-Path $zipUrl -Leaf - try { $sums = Get-WebString $sumUrl 10 } catch { return $null } - - foreach ($line in $sums -split "`r?`n") { - if ($line -match "^([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($zipName))\s*$") { - return $Matches[1].ToLower() - } - } - return $null -} - -# Fetch a sibling .sha256 file (xdebug.org convention) and return its digest. -function Get-XDebugHash ([string]$dllUrl) { - try { $content = Get-WebString "$dllUrl.sha256" 10 } catch { return $null } - if ($content -match '([0-9a-fA-F]{64})') { return $Matches[1].ToLower() } - return $null -} - -function Test-URLExists ([string]$url) { - $ProgressPreference = "SilentlyContinue" - # HEAD via Invoke-WebRequest follows 30x redirects (windows.php.net -> downloads.php.net). - try { - $r = Invoke-WebRequest -Uri $url -Method Head -MaximumRedirection 5 ` - -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop - return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) - } catch { - # Some mirrors reject HEAD (405) -- fall back to a 1-byte ranged GET. - try { - $r = Invoke-WebRequest -Uri $url -Method Get -MaximumRedirection 5 ` - -UseBasicParsing -TimeoutSec 5 ` - -Headers @{ Range = "bytes=0-0" } -ErrorAction Stop - return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) - } catch { return $false } - } -} - +# --- src/40-install.ps1 ------------------------------------------------------------ # ============================================================================== # CORE COMMANDS # ============================================================================== @@ -601,6 +614,7 @@ function Show-OlderPatchHint ([string]$ver) { Write-Dim "Remove it with: phpvm uninstall $($older[-1])" } +# --- src/45-version.ps1 ------------------------------------------------------------ function Invoke-Use ([string]$ver) { if (-not $ver) { Write-Err "Usage: phpvm use "; return } @@ -704,6 +718,7 @@ function Invoke-Ini { } } +# --- src/50-auto.ps1 --------------------------------------------------------------- # ============================================================================== # AUTO-SWITCH (.phpvmrc) # ============================================================================== @@ -798,6 +813,7 @@ function Invoke-Auto ([switch]$Silent) { } } +# --- src/55-hook.ps1 --------------------------------------------------------------- # Manage the $PROFILE snippet that runs `phpvm auto -Silent` on each prompt. $script:PHPVM_HOOK_MARKER = '# phpvm-auto-hook (managed by `phpvm hook`)' @@ -880,6 +896,7 @@ function Invoke-Hook ([string]$sub) { } } +# --- src/60-ext.ps1 ---------------------------------------------------------------- # ============================================================================== # EXT COMMANDS # ============================================================================== @@ -1335,6 +1352,8 @@ function Show-ExtHelp { "@ -ForegroundColor Cyan } +# --- src/70-composer.ps1 ----------------------------------------------------------- + function Invoke-Composer { $info = Get-PHPBuildInfo $loaded = (& $info.Exe -m 2>$null) | ForEach-Object { $_.Trim().ToLower() } @@ -1412,6 +1431,7 @@ php "$composerPhar" %* Write-Dim "Composer follows your active PHP version - no need to re-run after 'phpvm use'." } +# --- src/72-wpcli.ps1 -------------------------------------------------------------- function Invoke-WpCli { $info = Get-PHPBuildInfo @@ -1467,66 +1487,7 @@ php "$wpPhar" %* Write-Dim "WP-CLI follows your active PHP version - no need to re-run after 'phpvm use'." } -function Show-Help { - Write-Host @" - - phpvm $PHPVM_VERSION - PHP Version Manager for Windows - --------------------------------------------------------- - - VERSION MANAGEMENT - phpvm install Download & install a PHP version - --no-use install without switching to it - --no-cacert skip CA bundle configuration - 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.exe - phpvm ini Open active php.ini in Notepad - phpvm fix-ini Sync extension_dir & CA bundle in active php.ini - phpvm cacert [status|update] Manage the shared CA bundle (HTTPS/TLS) - phpvm doctor Diagnose PATH, ext_dir, CA bundle, VC++ runtime - - 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 (PowerShell prompt hook) - 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 Show all bundled extensions - phpvm ext enable Enable a bundled extension - phpvm ext install Install from PECL / xdebug.org - phpvm ext help Full extension reference (list, loaded, - disable, info, laravel, examples) - - EXAMPLES - phpvm install 8.3.0 - phpvm install 8.1.29 - phpvm use 8.3.0 - phpvm ext enable mbstring - phpvm ext enable pdo_mysql - phpvm ext install redis - phpvm ext install xdebug - - Home: $PHPVM_DIR - -"@ -ForegroundColor Cyan -} - +# --- src/80-maint.ps1 -------------------------------------------------------------- function Invoke-FixIni { $cur = Get-CurrentVersion if (-not $cur) { Write-Err "No active PHP version. Run: phpvm use "; return } @@ -1724,6 +1685,66 @@ function Invoke-Upgrade { } } +# --- src/90-help.ps1 --------------------------------------------------------------- +function Show-Help { + Write-Host @" + + phpvm $PHPVM_VERSION - PHP Version Manager for Windows + --------------------------------------------------------- + + VERSION MANAGEMENT + phpvm install Download & install a PHP version + --no-use install without switching to it + --no-cacert skip CA bundle configuration + 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.exe + phpvm ini Open active php.ini in Notepad + phpvm fix-ini Sync extension_dir & CA bundle in active php.ini + phpvm cacert [status|update] Manage the shared CA bundle (HTTPS/TLS) + phpvm doctor Diagnose PATH, ext_dir, CA bundle, VC++ runtime + + 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 (PowerShell prompt hook) + 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 Show all bundled extensions + phpvm ext enable Enable a bundled extension + phpvm ext install Install from PECL / xdebug.org + phpvm ext help Full extension reference (list, loaded, + disable, info, laravel, examples) + + EXAMPLES + phpvm install 8.3.0 + phpvm install 8.1.29 + phpvm use 8.3.0 + phpvm ext enable mbstring + phpvm ext enable pdo_mysql + phpvm ext install redis + phpvm ext install xdebug + + Home: $PHPVM_DIR + +"@ -ForegroundColor Cyan +} # -- Did-you-mean (unknown command handling) ----------------------------------- # Iterative Levenshtein distance (two-row, O(n) memory). @@ -1763,6 +1784,7 @@ function Invoke-Unknown ([string]$cmd) { Write-Dim "Run 'phpvm help' to see all commands." } +# --- src/99-entry.ps1 -------------------------------------------------------------- # Tests dot-source this file and set $env:PHPVM_NO_ENTRY=1 to skip the entry point. if (-not $env:PHPVM_NO_ENTRY) { Initialize-PHPVM diff --git a/windows/src/00-header.ps1 b/windows/src/00-header.ps1 new file mode 100644 index 0000000..ce6e2cf --- /dev/null +++ b/windows/src/00-header.ps1 @@ -0,0 +1,29 @@ +# ============================================================================== +# phpvm.ps1 - PHP Version Manager for Windows +# Compatible with: CMD (via phpvm.cmd shim) and PowerShell +# Repo: https://github.com/devhardiyanto/phpvm +# ============================================================================== + +param( + [Parameter(Position = 0)] [string]$Command = "", + [Parameter(Position = 1)] [string]$SubOrVer = "", + [Parameter(Position = 2)] [string]$Arg2 = "", + [Parameter(Position = 3)] [string]$Arg3 = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# -- Constants ----------------------------------------------------------------- +$PHPVM_VERSION = "1.12.4" +$PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } +$VERSIONS_DIR = "$PHPVM_DIR\versions" +$CURRENT_LINK = "$PHPVM_DIR\current" +$PHPVM_BIN = "$PHPVM_DIR\bin" +$PHPVM_CACERT = "$PHPVM_DIR\cacert.pem" +$PHPVM_CACERT_URL = "https://curl.se/ca/cacert.pem" + +# -- Update checker (hourly, via version.txt) --------------------------------- +$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 in seconds diff --git a/windows/src/10-output.ps1 b/windows/src/10-output.ps1 new file mode 100644 index 0000000..efcedf2 --- /dev/null +++ b/windows/src/10-output.ps1 @@ -0,0 +1,74 @@ +function Check-PHPVMUpdate { + if ($env:CI -or $env:PHPVM_NO_UPDATE_CHECK) { return } + + if (Test-Path $PHPVM_LAST_CHECK) { + $lastCheck = (Get-Item $PHPVM_LAST_CHECK).LastWriteTime + $elapsed = (Get-Date) - $lastCheck + if ($elapsed.TotalSeconds -lt $PHPVM_CHECK_INTERVAL) { return } + } + + # Touch before fetch so a slow request doesn't trigger repeated retries + [System.IO.File]::WriteAllText($PHPVM_LAST_CHECK, (Get-Date).ToString()) + + try { + $latest = (Get-WebString $PHPVM_UPDATE_URL 3).Trim() + if ([string]::IsNullOrEmpty($latest)) { return } + + $current = [version]$PHPVM_VERSION + $remote = [version]$latest + + if ($remote -gt $current) { + Write-Host "" + Write-Host " +-------------------------------------------------+" -ForegroundColor Yellow + Write-Host " | phpvm update available: $PHPVM_VERSION -> $latest" -ForegroundColor Yellow + Write-Host " | Get it: https://github.com/devhardiyanto/phpvm |" -ForegroundColor Yellow + Write-Host " +-------------------------------------------------+" -ForegroundColor Yellow + Write-Host "" + } + } catch { + return + } +} + + +function Write-Ok ($m) { Write-Host " $m" -ForegroundColor Green } +function Write-Err ($m) { Write-Host " [error] $m" -ForegroundColor Red } +function Write-Step ($m) { Write-Host " > $m" -ForegroundColor Cyan } +function Write-Warn ($m) { Write-Host " [warn] $m" -ForegroundColor Yellow } +function Write-Dim ($m) { Write-Host " $m" -ForegroundColor DarkGray } + +# Broadcast WM_SETTINGCHANGE so running processes (Explorer, and the terminals +# it spawns afterward) refresh their environment block after a User PATH change, +# instead of needing a logout. Best-effort; any failure is swallowed. +function Send-EnvChangeBroadcast { + if (-not ("PHPVM.NativeMethods" -as [type])) { + try { + Add-Type -Namespace PHPVM -Name NativeMethods -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)] +public static extern System.IntPtr SendMessageTimeout( + System.IntPtr hWnd, uint Msg, System.IntPtr wParam, string lParam, + uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult); +'@ + } catch { return } + } + $HWND_BROADCAST = [System.IntPtr]0xffff + $WM_SETTINGCHANGE = 0x1A + $SMTO_ABORTIFHUNG = 0x2 + $out = [System.UIntPtr]::Zero + try { + [void][PHPVM.NativeMethods]::SendMessageTimeout( + $HWND_BROADCAST, $WM_SETTINGCHANGE, [System.IntPtr]::Zero, + "Environment", $SMTO_ABORTIFHUNG, 5000, [ref]$out) + } catch { $null = $_ } +} + +# -- Init ---------------------------------------------------------------------- +function Initialize-PHPVM { + # GitHub blocks TLS < 1.2 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + foreach ($d in @($PHPVM_DIR, $VERSIONS_DIR, $PHPVM_BIN)) { + if (-not (Test-Path $d)) { + New-Item -ItemType Directory -Path $d -Force | Out-Null + } + } +} diff --git a/windows/src/20-phpinfo.ps1 b/windows/src/20-phpinfo.ps1 new file mode 100644 index 0000000..78e513d --- /dev/null +++ b/windows/src/20-phpinfo.ps1 @@ -0,0 +1,147 @@ +# -- PHP build metadata -------------------------------------------------------- +# Some Windows PHP builds leak warnings into stdout; strip them. +function Invoke-PHP ([string]$exe, [string]$code) { + $out = & $exe -r $code 2>$null + $clean = $out | Where-Object { $_ -notmatch "^(PHP )?(Warning|Notice|Deprecated|Fatal|Parse)" } + return ($clean -join "").Trim() +} + +function Get-PHPBuildInfo ([string]$phpExe = "") { + if (-not $phpExe) { + if (Test-Path "$CURRENT_LINK\php.exe") { $phpExe = "$CURRENT_LINK\php.exe" } + else { throw "No active PHP version. Run: phpvm use " } + } + + $raw = (& $phpExe -i 2>$null) | Where-Object { $_ -notmatch "^(PHP )?(Warning|Notice|Deprecated)" } + + $version = Invoke-PHP $phpExe "echo PHP_VERSION;" + if ($version -match '(\d+\.\d+\.\d+)') { $version = $Matches[1] } + $short = $version -replace '^(\d+\.\d+)\..*', '$1' + + # Both lines can be absent when php -i fails or emits garbage; .ToString() + # on the empty pipeline would throw a raw MethodInvocationException. + $tsLine = $raw | Select-String "Thread Safety" | Select-Object -First 1 + $isTS = $tsLine -and ($tsLine.ToString() -match "enabled") + + $compLine = $raw | Select-String "Compiler" | Select-Object -First 1 + $compLine = if ($compLine) { $compLine.ToString() } else { "" } + $vs = switch -Regex ($compLine) { + "MSVC17|VS17" { "vs17"; break } + "MSVC16|VS16" { "vs16"; break } + "MSVC15|VS15" { "vs15"; break } + default { Get-VSVersion $version } + } + + # Derive ext dir from exe; php.ini may still point at a system PHP. + $phpRoot = Split-Path $phpExe -Parent + $extDir = "$phpRoot\ext" + + $iniPath = Invoke-PHP $phpExe "echo php_ini_loaded_file();" + + return @{ + Version = $version + Short = $short + TS = if ($isTS) { "ts" } else { "nts" } + VS = $vs + Arch = "x64" + Exe = $phpExe + Root = $phpRoot + ExtDir = $extDir + IniPath = $iniPath + } +} + + +# -- Resolve PHP download URL -------------------------------------------------- +# Per windows.php.net: 5.x -> vc11, 7.0-7.1 -> vc14, 7.2-7.4 -> vc15, +# 8.0-8.3 -> vs16, 8.4+ -> vs17. +function Get-VSVersion ([string]$ver) { + # Anything that isn't x.y... would blow up the [int] casts below. + if ($ver -notmatch '^\d+\.\d+') { return "vs17" } + $major = [int]($ver -split '\.')[0] + $minor = [int]($ver -split '\.')[1] + if ($major -eq 5) { return "vc11" } + if ($major -eq 7 -and $minor -le 1) { return "vc14" } + if ($major -eq 7) { return "vc15" } + if ($major -eq 8 -and $minor -le 3) { return "vs16" } + if ($major -eq 8 -and $minor -ge 4) { return "vs17" } + return "vs17" +} + +function Resolve-PHPURL ([string]$ver) { + $vs = Get-VSVersion $ver + $urls = @( + "https://windows.php.net/downloads/releases/php-$ver-Win32-$vs-x64.zip" + "https://windows.php.net/downloads/releases/php-$ver-nts-Win32-$vs-x64.zip" + "https://windows.php.net/downloads/releases/archives/php-$ver-Win32-$vs-x64.zip" + "https://windows.php.net/downloads/releases/archives/php-$ver-nts-Win32-$vs-x64.zip" + ) + foreach ($url in $urls) { + try { + $req = [System.Net.WebRequest]::Create($url) + $req.Method = "HEAD" + $req.Timeout = 5000 + $res = $req.GetResponse() + $res.Close() + return $url + } catch { + continue + } + } + return $null +} + +# Resolve a partial version to the highest patch published on windows.php.net. +# "8" -> latest 8.x (e.g. 8.5.7) +# "8.3" -> latest 8.3.x (e.g. 8.3.31) +function Resolve-LatestPatch ([string]$request) { + $found = @() + # Capture the full x.y.z from any Win32 build name. + # (?i) - older archives use uppercase (VC11/VC14/VC15); newer lowercase (vs16/vs17). + $pattern = '(?i)php-(\d+\.\d+\.\d+)-(?:nts-)?Win32-(?:vs1[567]|vc1[145])-x64\.zip' + + foreach ($index in @( + "https://windows.php.net/downloads/releases/" + "https://windows.php.net/downloads/releases/archives/" + )) { + try { + $html = Get-WebString $index 5 + } catch { continue } + foreach ($m in [regex]::Matches($html, $pattern)) { + $found += $m.Groups[1].Value + } + } + + if (-not $found) { return $null } + # Keep versions whose prefix matches the request. The '(\.|$)' guard stops + # "8.3" from matching "8.30.x" and "8" from matching "18.x". + $filter = '^' + [regex]::Escape($request) + '(\.|$)' + $cand = $found | Where-Object { $_ -match $filter } + if (-not $cand) { return $null } + return ($cand | Sort-Object -Unique | Sort-Object { [version]$_ } -Descending | Select-Object -First 1) +} + +# -- Junction helpers ---------------------------------------------------------- +function Get-CurrentVersion { + if (-not (Test-Path $CURRENT_LINK)) { return $null } + $item = Get-Item $CURRENT_LINK -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + # On PS 5.1 Target can be missing or empty for some reparse points; + # Split-Path $null would throw under StrictMode. + $target = if ($item.PSObject.Properties['Target']) { @($item.Target)[0] } else { $null } + if (-not $target) { return $null } + return Split-Path $target -Leaf + } + return $null +} + +function Remove-Junction ([string]$path) { + if (Test-Path $path) { + $item = Get-Item $path -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + [System.IO.Directory]::Delete($path) + } else { + Remove-Item $path -Recurse -Force + } + } +} diff --git a/windows/src/30-net.ps1 b/windows/src/30-net.ps1 new file mode 100644 index 0000000..dfef876 --- /dev/null +++ b/windows/src/30-net.ps1 @@ -0,0 +1,136 @@ +# -- Download helper ----------------------------------------------------------- +# Progress is only worth drawing for the PHP zips (tens of MB); the Xdebug DLL +# and ext zips are small enough that a bar would just flicker. +$script:PROGRESS_MIN_BYTES = 5MB + +function Format-Bytes ([double]$bytes) { + if ($bytes -ge 1GB) { return "{0:N1} GB" -f ($bytes / 1GB) } + if ($bytes -ge 1MB) { return "{0:N1} MB" -f ($bytes / 1MB) } + return "{0:N0} KB" -f ($bytes / 1KB) +} + +function Format-Duration ([double]$seconds) { + if ($seconds -lt 0 -or [double]::IsInfinity($seconds) -or [double]::IsNaN($seconds)) { return "--:--" } + $ts = [TimeSpan]::FromSeconds([Math]::Round($seconds)) + if ($ts.TotalHours -ge 1) { return "{0:d1}:{1:d2}:{2:d2}" -f [int]$ts.TotalHours, $ts.Minutes, $ts.Seconds } + return "{0:d2}:{1:d2}" -f $ts.Minutes, $ts.Seconds +} + +# Streams $url to $dest, drawing a byte-level progress line on stderr so stdout +# stays pipe-clean. Falls back to a plain copy when the size is unknown, the +# payload is small, or stderr is redirected (CI, tests). +function Invoke-Download ([string]$url, [string]$dest) { + $ProgressPreference = "SilentlyContinue" + + $resp = $null + try { + $req = [System.Net.HttpWebRequest]::Create($url) + $req.UserAgent = "phpvm/$PHPVM_VERSION" + $resp = $req.GetResponse() + $total = [long]$resp.ContentLength + } catch { + if ($resp) { $resp.Dispose() } + # Anything odd about the response: let Invoke-WebRequest deal with it. + Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing + return + } + + $showProgress = ($total -ge $script:PROGRESS_MIN_BYTES) -and (-not [Console]::IsErrorRedirected) + + $input_ = $resp.GetResponseStream() + $output = [System.IO.File]::Create($dest) + $buffer = New-Object byte[] 81920 + $read = 0 + $sw = [Diagnostics.Stopwatch]::StartNew() + $lastDraw = 0 + + try { + while (($n = $input_.Read($buffer, 0, $buffer.Length)) -gt 0) { + $output.Write($buffer, 0, $n) + $read += $n + + if (-not $showProgress) { continue } + # Throttle redraws; repainting per 80 KB chunk is pure overhead. + if ($sw.ElapsedMilliseconds - $lastDraw -lt 120 -and $read -lt $total) { continue } + $lastDraw = $sw.ElapsedMilliseconds + + $elapsed = [Math]::Max($sw.Elapsed.TotalSeconds, 0.001) + $speed = $read / $elapsed + $eta = if ($speed -gt 0) { ($total - $read) / $speed } else { -1 } + $pct = [int](100 * $read / $total) + + $line = " {0,3}% {1} / {2} ({3}/s, eta {4})" -f ` + $pct, (Format-Bytes $read), (Format-Bytes $total), + (Format-Bytes $speed), (Format-Duration $eta) + [Console]::Error.Write(("`r" + $line.PadRight(70))) + } + } finally { + $output.Dispose() + $input_.Dispose() + $resp.Dispose() + if ($showProgress) { [Console]::Error.Write("`r" + (" " * 70) + "`r") } + } +} + + +function Get-WebString ([string]$url, [int]$timeoutSec = 5) { + $ProgressPreference = "SilentlyContinue" + $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $timeoutSec + $c = $resp.Content + if ($c -is [byte[]]) { $c = [System.Text.Encoding]::UTF8.GetString($c) } + return [string]$c +} + +function Unblock-PHPVMPath ([string]$path) { + if (-not (Test-Path $path)) { return } + + try { + Unblock-File -Path $path -ErrorAction SilentlyContinue + if (Test-Path $path -PathType Container) { + Get-ChildItem -Path $path -Recurse -Force -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + } + } catch { + return + } +} + +# Look up the expected SHA-256 for a PHP zip on windows.php.net. +# Returns lowercase hex digest, or $null if no checksum is published. +function Get-PHPZipHash ([string]$zipUrl) { + $sumUrl = ($zipUrl -replace '/[^/]+\.zip$', '/') + 'sha256sum.txt' + $zipName = Split-Path $zipUrl -Leaf + try { $sums = Get-WebString $sumUrl 10 } catch { return $null } + + foreach ($line in $sums -split "`r?`n") { + if ($line -match "^([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($zipName))\s*$") { + return $Matches[1].ToLower() + } + } + return $null +} + +# Fetch a sibling .sha256 file (xdebug.org convention) and return its digest. +function Get-XDebugHash ([string]$dllUrl) { + try { $content = Get-WebString "$dllUrl.sha256" 10 } catch { return $null } + if ($content -match '([0-9a-fA-F]{64})') { return $Matches[1].ToLower() } + return $null +} + +function Test-URLExists ([string]$url) { + $ProgressPreference = "SilentlyContinue" + # HEAD via Invoke-WebRequest follows 30x redirects (windows.php.net -> downloads.php.net). + try { + $r = Invoke-WebRequest -Uri $url -Method Head -MaximumRedirection 5 ` + -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) + } catch { + # Some mirrors reject HEAD (405) -- fall back to a 1-byte ranged GET. + try { + $r = Invoke-WebRequest -Uri $url -Method Get -MaximumRedirection 5 ` + -UseBasicParsing -TimeoutSec 5 ` + -Headers @{ Range = "bytes=0-0" } -ErrorAction Stop + return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) + } catch { return $false } + } +} diff --git a/windows/src/35-cacert.ps1 b/windows/src/35-cacert.ps1 new file mode 100644 index 0000000..8f0393f --- /dev/null +++ b/windows/src/35-cacert.ps1 @@ -0,0 +1,52 @@ +# -- CA bundle (curl.cainfo / openssl.cafile) ---------------------------------- +# Windows PHP builds ship no CA bundle, so every HTTPS request from PHP fails +# with cURL error 60 until one is configured. One shared bundle in $PHPVM_DIR +# serves all installed versions. + +# Ensure $PHPVM_CACERT exists; download the Mozilla bundle if missing (or -Force). +# Best-effort: returns the bundle path, or $null when absent and undownloadable. +# Never throws - an offline install must still succeed. +function Get-CABundle ([switch]$Force) { + if ((Test-Path $PHPVM_CACERT) -and -not $Force) { return $PHPVM_CACERT } + + Write-Step "Downloading CA bundle (curl.se/ca/cacert.pem) ..." + $tmp = "$env:TEMP\phpvm-cacert.pem" + try { + Invoke-Download $PHPVM_CACERT_URL $tmp + $head = Get-Content $tmp -TotalCount 200 -ErrorAction Stop + if (-not ($head -match "BEGIN CERTIFICATE")) { throw "not a PEM bundle" } + Move-Item $tmp $PHPVM_CACERT -Force + Write-Ok "CA bundle saved: $PHPVM_CACERT" + } catch { + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + Write-Warn "Could not fetch CA bundle: $_" + if (Test-Path $PHPVM_CACERT) { return $PHPVM_CACERT } + Write-Dim "HTTPS from PHP may fail with cURL error 60. Retry later: phpvm cacert update" + return $null + } + return $PHPVM_CACERT +} + +# Point curl.cainfo and openssl.cafile at $bundlePath in raw php.ini content. +# Uncomments/overwrites existing directives; appends a block when absent. +function Set-IniCACert ([string]$content, [string]$bundlePath) { + foreach ($key in @("curl.cainfo", "openssl.cafile")) { + $pattern = "(?m)^;*\s*$([regex]::Escape($key))\s*=.*$" + $line = "$key = `"$bundlePath`"" + if ($content -match $pattern) { + $content = [regex]::Replace($content, $pattern, $line.Replace('$', '$$')) + } else { + $content = $content.TrimEnd() + "`r`n$line`r`n" + } + } + return $content +} + +# Apply the shared bundle to one php.ini file. No-op if either is missing. +function Update-IniCACert ([string]$iniPath, [string]$bundlePath) { + if (-not $bundlePath -or -not (Test-Path $iniPath)) { return $false } + $before = Get-Content $iniPath -Raw + $after = Set-IniCACert $before $bundlePath + if ($after -ne $before) { $after | Set-Content $iniPath -NoNewline } + return $true +} diff --git a/windows/src/40-install.ps1 b/windows/src/40-install.ps1 new file mode 100644 index 0000000..c7d2b95 --- /dev/null +++ b/windows/src/40-install.ps1 @@ -0,0 +1,159 @@ +# ============================================================================== +# CORE COMMANDS +# ============================================================================== + +function Invoke-Install ([string]$ver, [string]$flag) { + # Accept flags in either position, matching the Linux arg loop. + $noUse = $false + $noCacert = $false + $positional = @() + foreach ($a in @($ver, $flag)) { + if (-not $a) { continue } + if ($a -eq "--no-use") { $noUse = $true } + elseif ($a -eq "--no-cacert") { $noCacert = $true } + elseif ($a -like "-*") { Write-Err "Unknown option: $a. Usage: phpvm install [--no-use] [--no-cacert]"; return } + else { $positional += $a } + } + $ver = if ($positional.Count -gt 0) { $positional[0] } else { "" } + + if (-not $ver) { Write-Err "Usage: phpvm install [--no-use] (e.g. phpvm install 8.3.0)"; return } + + # Allow "8" -> latest 8.x and "8.3" -> latest 8.3.x. + if ($ver -match '^\d+(\.\d+)?$') { + Write-Step "Resolving latest patch for PHP $ver ..." + $resolved = Resolve-LatestPatch $ver + if (-not $resolved) { + Write-Err "No patch releases found for PHP $ver" + Write-Dim "Browse: https://windows.php.net/downloads/releases/" + return + } + Write-Ok "Latest PHP $ver -> $resolved" + $ver = $resolved + } + + # Anything that isn't a full x.y.z here would blow up later in Get-VSVersion's + # [int] cast with a raw PowerShell exception. + if ($ver -notmatch '^\d+\.\d+\.\d+$') { + Write-Err "Invalid version '$ver'. Usage: phpvm install (e.g. phpvm install 8.3.0)" + if ($ver -eq "composer") { Write-Dim "Did you mean: phpvm composer" } + return + } + + $targetDir = "$VERSIONS_DIR\$ver" + if (Test-Path $targetDir) { + Write-Warn "PHP $ver is already installed. Run: phpvm use $ver" + return + } + + Write-Step "Resolving download for PHP $ver ..." + $url = Resolve-PHPURL $ver + if (-not $url) { + Write-Err "PHP $ver not found on windows.php.net" + Write-Dim "" + Write-Dim "Available versions (latest per branch):" + Write-Dim " PHP 8.5.x -> phpvm install 8.5.1" + Write-Dim " PHP 8.4.x -> phpvm install 8.4.16" + Write-Dim " PHP 8.3.x -> phpvm install 8.3.29" + Write-Dim " PHP 8.2.x -> phpvm install 8.2.30" + Write-Dim " PHP 8.1.x -> phpvm install 8.1.34" + Write-Dim " PHP 7.4.x -> phpvm install 7.4.33" + Write-Dim "" + Write-Dim "Full list: https://windows.php.net/downloads/releases/" + return + } + + $tempFile = "$env:TEMP\phpvm-php-$ver.zip" + + Write-Step "Downloading $(Split-Path $url -Leaf) ..." + try { Invoke-Download $url $tempFile } + catch { Write-Err "Download failed: $_"; return } + + if (-not $env:PHPVM_SKIP_HASH) { + Write-Step "Verifying SHA-256 ..." + $expected = Get-PHPZipHash $url + if ($expected) { + $actual = (Get-FileHash -Path $tempFile -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + Write-Err "SHA-256 mismatch! Aborting." + Write-Dim " expected: $expected" + Write-Dim " actual: $actual" + Remove-Item $tempFile -Force + return + } + Write-Ok "SHA-256 verified." + } else { + Write-Warn "No published SHA-256 for $(Split-Path $url -Leaf); continuing unverified." + } + } + + Unblock-PHPVMPath $tempFile + + Write-Step "Extracting ..." + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + Expand-Archive -Path $tempFile -DestinationPath $targetDir -Force + Remove-Item $tempFile -Force + + if (-not (Test-Path "$targetDir\php.ini")) { + $src = @("$targetDir\php.ini-development", "$targetDir\php.ini-production") | + Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($src) { Copy-Item $src "$targetDir\php.ini" } + } + + # Pin extension_dir to this version's ext folder (absolute path). + $ini = "$targetDir\php.ini" + if (Test-Path $ini) { + $content = Get-Content $ini -Raw + $extPath = "$targetDir\ext" + $content = $content -replace '(?m)^;?\s*extension_dir\s*=.*$', "extension_dir = `"$extPath`"" + $content | Set-Content $ini -NoNewline + } + + # Windows PHP has no CA bundle -> HTTPS from PHP fails (cURL error 60). + # Point this version at the shared bundle, unless opted out. + if (-not $noCacert) { + $bundle = Get-CABundle + if ($bundle -and (Update-IniCACert $ini $bundle)) { + Write-Ok "CA bundle configured (curl.cainfo / openssl.cafile)." + } + } + + Write-Ok "PHP $ver installed successfully." + + # Activate the freshly installed version right away, unless opted out. + if ($noUse) { + Write-Dim "Not switching (--no-use). Run: phpvm use $ver" + } else { + Invoke-Use $ver + } + + Show-OlderPatchHint $ver +} + +# `phpvm install 8` resolves to the newest patch and installs it alongside any +# older patch of the same line. Point that out rather than removing it: another +# project may still pin the old patch in .phpvmrc. +function Get-OlderPatch ([string]$ver) { + if ($ver -notmatch '^\d+\.\d+\.\d+$') { return @() } + if (-not (Test-Path $VERSIONS_DIR)) { return @() } + + $parts = $ver -split '\.' + $line = "$($parts[0]).$($parts[1])" + + return @( + Get-ChildItem $VERSIONS_DIR -Directory -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name | + Where-Object { $_ -match '^\d+\.\d+\.\d+$' -and $_ -like "$line.*" } | + Where-Object { [version]$_ -lt [version]$ver } | + Sort-Object { [version]$_ } + ) +} + +function Show-OlderPatchHint ([string]$ver) { + # The pipeline unrolls a one-element return, and StrictMode has no .Count + # (nor a working [-1]) on the bare string that leaves behind. + $older = @(Get-OlderPatch $ver) + if ($older.Count -eq 0) { return } + + Write-Dim "Older patch of $(($ver -split '\.')[0..1] -join '.') still installed: $($older -join ', ')" + Write-Dim "Remove it with: phpvm uninstall $($older[-1])" +} diff --git a/windows/src/45-version.ps1 b/windows/src/45-version.ps1 new file mode 100644 index 0000000..ef891ee --- /dev/null +++ b/windows/src/45-version.ps1 @@ -0,0 +1,102 @@ +function Invoke-Use ([string]$ver) { + if (-not $ver) { Write-Err "Usage: phpvm use "; return } + + $targetDir = "$VERSIONS_DIR\$ver" + if (-not (Test-Path $targetDir)) { + Write-Err "PHP $ver is not installed. Run: phpvm install $ver" + return + } + if (-not (Test-Path "$targetDir\php.exe")) { + Write-Err "Invalid PHP $ver install: missing $targetDir\php.exe" + return + } + + Remove-Junction $CURRENT_LINK + cmd /c mklink /J `"$CURRENT_LINK`" `"$targetDir`" | Out-Null + + # Persist + apply to current session (idempotent). + $userPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if ($null -eq $userPath) { $userPath = "" } + $parts = $userPath -split ";" | Where-Object { $_ -and $_ -ne $CURRENT_LINK } + $newPath = (@($CURRENT_LINK) + $parts -join ";") -replace ";{2,}", ";" + [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") + if ($env:PATH -notlike "*$CURRENT_LINK*") { $env:PATH = "$CURRENT_LINK;$env:PATH" } + # Propagate the PATH change to the rest of the system so new terminals get it + # without a logout. This session was already updated on the line above. + Send-EnvChangeBroadcast + + Write-Ok "Now using PHP $ver" + try { + & "$CURRENT_LINK\php.exe" --version 2>$null | Select-Object -First 1 | ForEach-Object { Write-Host " $_" } + } catch { + return + } + Write-Dim "Active in this terminal now. Other already-open terminals pick it up when reopened." +} + +function Invoke-List { + $versions = if (Test-Path $VERSIONS_DIR) { + Get-ChildItem $VERSIONS_DIR -Directory | Sort-Object Name + } else { @() } + + Write-Host "" + if (-not $versions) { Write-Dim "No PHP versions installed."; Write-Host ""; return } + + $current = Get-CurrentVersion + Write-Host " Installed versions:" -ForegroundColor Cyan + foreach ($v in $versions) { + if ($v.Name -eq $current) { + Write-Host " -> $($v.Name) (active)" -ForegroundColor Green + } else { + Write-Host " $($v.Name)" -ForegroundColor Gray + } + } + Write-Host "" +} + +function Invoke-Current { + $cur = Get-CurrentVersion + if ($cur) { + Write-Host "" + Write-Host " Active: $cur" -ForegroundColor Green + try { + & "$CURRENT_LINK\php.exe" --version 2>$null | ForEach-Object { Write-Host " $_" } + } catch { + return + } + Write-Host "" + } else { + Write-Warn "No PHP version active. Run: phpvm use " + } +} + +function Invoke-Uninstall ([string]$ver) { + if (-not $ver) { Write-Err "Usage: phpvm uninstall "; return } + + $targetDir = "$VERSIONS_DIR\$ver" + if (-not (Test-Path $targetDir)) { Write-Err "PHP $ver is not installed."; return } + if ((Get-CurrentVersion) -eq $ver) { + Write-Err "Cannot uninstall the active version. Switch first: phpvm use " + return + } + + Remove-Item $targetDir -Recurse -Force + Write-Ok "PHP $ver has been removed." +} + +function Invoke-Which { + try { Write-Ok (Get-Command php -ErrorAction Stop).Source } + catch { Write-Warn "php not found in PATH" } +} + +function Invoke-Ini { + $cur = Get-CurrentVersion + if (-not $cur) { Write-Err "No active PHP version."; return } + $ini = "$VERSIONS_DIR\$cur\php.ini" + if (Test-Path $ini) { + Write-Step "Opening $ini" + Start-Process notepad $ini + } else { + Write-Err "php.ini not found: $ini" + } +} diff --git a/windows/src/50-auto.ps1 b/windows/src/50-auto.ps1 new file mode 100644 index 0000000..bd2b336 --- /dev/null +++ b/windows/src/50-auto.ps1 @@ -0,0 +1,93 @@ +# ============================================================================== +# AUTO-SWITCH (.phpvmrc) +# ============================================================================== + +# Walk from $startDir up to the drive root looking for a .phpvmrc file. +function Find-PHPVMRC ([string]$startDir = '') { + if (-not $startDir) { $startDir = (Get-Location).Path } + $dir = $startDir + while ($dir) { + $rc = Join-Path $dir '.phpvmrc' + if (Test-Path $rc -PathType Leaf) { return $rc } + $parent = Split-Path $dir -Parent + if (-not $parent -or $parent -eq $dir) { return $null } + $dir = $parent + } + return $null +} + +# Return the first non-comment, non-empty line of an rc file. Strips a leading +# `v` (some users write `v8.3.0`) and any trailing whitespace. +function Read-PHPVMRC ([string]$rcFile) { + if (-not (Test-Path $rcFile -PathType Leaf)) { return $null } + foreach ($line in (Get-Content $rcFile)) { + $line = ($line -replace '#.*$').Trim() + if ($line) { return ($line -replace '^v', '') } + } + return $null +} + +# Map an rc version (8.3, 8.3.0, 5.6.40) onto an installed version directory. +# Full semver passes through if installed; partial picks the highest installed +# patch. Returns $null if no matching version is installed locally. +function Resolve-RCVersion ([string]$requested) { + if (-not $requested) { return $null } + $target = "$VERSIONS_DIR\$requested" + if (Test-Path "$target\php.exe") { return $requested } + + if ($requested -match '^\d+\.\d+$') { + $prefix = "$requested." + $match = Get-ChildItem $VERSIONS_DIR -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name.StartsWith($prefix) -and (Test-Path "$($_.FullName)\php.exe") } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 + if ($match) { return $match.Name } + } + return $null +} + +# Session-only PATH switch driven by .phpvmrc. Tracks the active version in +# $env:PHPVM_AUTO_ACTIVE so repeat calls are no-ops and leaving a project +# cleanly removes the prepended path. +function Invoke-Auto ([switch]$Silent) { + $rcFile = Find-PHPVMRC + + if (-not $rcFile) { + if ($env:PHPVM_AUTO_ACTIVE) { + $old = "$VERSIONS_DIR\$($env:PHPVM_AUTO_ACTIVE)" + $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -and $_ -ne $old }) -join ';' + $env:PHPVM_AUTO_ACTIVE = '' + if (-not $Silent) { Write-Dim "Cleared auto PHP (no .phpvmrc upstream)." } + } + return + } + + $requested = Read-PHPVMRC $rcFile + if (-not $requested) { + if (-not $Silent) { Write-Warn "$rcFile is empty or comment-only." } + return + } + + $resolved = Resolve-RCVersion $requested + if (-not $resolved) { + if (-not $Silent) { + Write-Warn "PHP $requested (from $rcFile) is not installed." + Write-Dim "Run: phpvm install $requested" + } + return + } + + if ($env:PHPVM_AUTO_ACTIVE -eq $resolved) { return } + + if ($env:PHPVM_AUTO_ACTIVE) { + $old = "$VERSIONS_DIR\$($env:PHPVM_AUTO_ACTIVE)" + $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -and $_ -ne $old }) -join ';' + } + + $new = "$VERSIONS_DIR\$resolved" + $env:PATH = "$new;$env:PATH" + $env:PHPVM_AUTO_ACTIVE = $resolved + if (-not $Silent) { + Write-Ok "Auto-switched to PHP $resolved (from $rcFile)" + } +} diff --git a/windows/src/55-hook.ps1 b/windows/src/55-hook.ps1 new file mode 100644 index 0000000..53e0686 --- /dev/null +++ b/windows/src/55-hook.ps1 @@ -0,0 +1,81 @@ +# Manage the $PROFILE snippet that runs `phpvm auto -Silent` on each prompt. +$script:PHPVM_HOOK_MARKER = '# phpvm-auto-hook (managed by `phpvm hook`)' + +function Get-PHPVMHookSnippet { + @" + +$($script:PHPVM_HOOK_MARKER) +if (Get-Command phpvm -ErrorAction SilentlyContinue) { + `$global:__phpvm_prev_prompt = `$function:prompt + function global:prompt { + try { phpvm auto -Silent } catch {} + if (`$global:__phpvm_prev_prompt) { & `$global:__phpvm_prev_prompt } + else { "PS `$(`$ExecutionContext.SessionState.Path.CurrentLocation)`$('>' * (`$nestedPromptLevel + 1)) " } + } +} +"@ +} + +function Install-PHPVMHook { + $profilePath = $PROFILE.CurrentUserCurrentHost + if (-not (Test-Path $profilePath)) { + New-Item -ItemType File -Path $profilePath -Force | Out-Null + } + $existing = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue + if ($existing -and $existing.Contains($script:PHPVM_HOOK_MARKER)) { + Write-Warn "phpvm hook already installed in $profilePath" + return + } + Add-Content -Path $profilePath -Value (Get-PHPVMHookSnippet) + Write-Ok "Installed hook -> $profilePath" + Write-Dim "Open a new PowerShell window to activate." +} + +function Uninstall-PHPVMHook { + $profilePath = $PROFILE.CurrentUserCurrentHost + if (-not (Test-Path $profilePath)) { + Write-Warn "No `$PROFILE found at $profilePath" + return + } + $content = Get-Content $profilePath -Raw + if (-not $content.Contains($script:PHPVM_HOOK_MARKER)) { + Write-Warn "phpvm hook not found in $profilePath" + return + } + # Strip from marker to the matching closing brace of the `if` block. + $pattern = "(?ms)\r?\n?" + [regex]::Escape($script:PHPVM_HOOK_MARKER) + ".*?^\}\s*" + $cleaned = [regex]::Replace($content, $pattern, '') + Set-Content -Path $profilePath -Value $cleaned -NoNewline + Write-Ok "Removed hook from $profilePath" + Write-Dim "Open a new PowerShell window for the change to take effect." +} + +function Show-PHPVMHookStatus { + $profilePath = $PROFILE.CurrentUserCurrentHost + if (-not (Test-Path $profilePath)) { + Write-Dim "No `$PROFILE at $profilePath - hook not installed." + return + } + $content = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue + if ($content -and $content.Contains($script:PHPVM_HOOK_MARKER)) { + Write-Ok "Hook installed in $profilePath" + } else { + Write-Dim "Hook not installed. Run: phpvm hook enable" + } +} + +function Invoke-Hook ([string]$sub) { + switch ($sub.ToLower()) { + 'enable' { Install-PHPVMHook } + 'disable' { Uninstall-PHPVMHook } + 'status' { Show-PHPVMHookStatus } + default { + Write-Host "" + Write-Host " phpvm hook - manage the PowerShell auto-switch hook" -ForegroundColor Cyan + Write-Host " phpvm hook enable Enable .phpvmrc auto-switching (prompt hook in `$PROFILE)" + Write-Host " phpvm hook disable Disable the hook" + Write-Host " phpvm hook status Check whether the hook is enabled" + Write-Host "" + } + } +} diff --git a/windows/src/60-ext.ps1 b/windows/src/60-ext.ps1 new file mode 100644 index 0000000..eee0831 --- /dev/null +++ b/windows/src/60-ext.ps1 @@ -0,0 +1,454 @@ +# ============================================================================== +# EXT COMMANDS +# ============================================================================== + +function Ext-List { + $info = Get-PHPBuildInfo + Write-Host "" + Write-Host " PHP $($info.Version) [$($info.TS.ToUpper()) / $($info.VS) / $($info.Arch)]" -ForegroundColor Cyan + Write-Host " php.ini : $($info.IniPath)" -ForegroundColor DarkGray + Write-Host " ext dir : $($info.ExtDir)" -ForegroundColor DarkGray + Write-Host "" + + if (-not (Test-Path $info.ExtDir)) { Write-Warn "ext/ directory not found."; return } + + $loaded = (& $info.Exe -m 2>$null) | ForEach-Object { $_.Trim().ToLower() } + $dlls = Get-ChildItem $info.ExtDir -Filter "php_*.dll" | Sort-Object Name + + Write-Host " EXTENSION STATUS" -ForegroundColor Yellow + Write-Host " -----------------------------" + foreach ($dll in $dlls) { + $name = $dll.BaseName -replace '^php_', '' + $on = $loaded -contains $name.ToLower() + $pad = $name.PadRight(22) + if ($on) { Write-Host " $pad [ON]" -ForegroundColor Green } + else { Write-Host " $pad [off]" -ForegroundColor DarkGray } + } + Write-Host "" + Write-Dim "phpvm ext enable phpvm ext disable phpvm ext install " + Write-Host "" +} + +function Ext-Loaded { + $info = Get-PHPBuildInfo + Write-Host "" + Write-Host " Loaded extensions - PHP $($info.Version):" -ForegroundColor Cyan + & $info.Exe -m 2>$null | Where-Object { $_ -notmatch '^\[' } | Sort-Object | + ForEach-Object { Write-Host " $_" -ForegroundColor Gray } + Write-Host "" +} + +function Edit-IniExtension ([string]$extName, [bool]$enable) { + $info = Get-PHPBuildInfo + $iniPath = $info.IniPath + + if (-not $iniPath -or -not (Test-Path $iniPath)) { + Write-Err "php.ini not found. Run: phpvm ini" + return + } + + $extLower = $extName.ToLower() + $content = Get-Content $iniPath -Raw + + $zendExts = @("xdebug", "opcache", "ioncube_loader") + $prefix = if ($zendExts -contains $extLower) { "zend_extension" } else { "extension" } + + # Matches `;extension=name`, `extension=name`, or `extension=php_name.dll`. + $linePattern = "(?im)^(;+\s*)?($prefix\s*=\s*(?:php_)?$([regex]::Escape($extLower))(?:\.dll)?)\s*$" + + if ($enable) { + if ($content -match $linePattern) { + $newContent = [regex]::Replace($content, $linePattern, '$2') + if ($newContent -eq $content) { Write-Warn "'$extName' is already enabled." } + else { $newContent | Set-Content $iniPath -NoNewline; Write-Ok "Enabled: $extName" } + } else { + $dllPath = "$($info.ExtDir)\php_$extLower.dll" + if (-not (Test-Path $dllPath)) { + Write-Err "DLL not found: $dllPath" + Write-Dim "Install it first: phpvm ext install $extName" + return + } + Add-Content $iniPath "`n$prefix=$extLower" + Write-Ok "Enabled: $extName (added to php.ini)" + } + } else { + if ($content -match $linePattern) { + $newContent = [regex]::Replace($content, $linePattern, ';$2') + $newContent | Set-Content $iniPath -NoNewline + Write-Ok "Disabled: $extName" + } else { + Write-Warn "'$extName' not found in php.ini." + } + } +} + +function Get-PECLVersions ([string]$extName) { + try { + $html = Get-WebString "https://windows.php.net/downloads/pecl/releases/$extName/" + $m = [regex]::Matches($html, 'href="(\d+\.\d+[\.\d]*)/?">') + return $m | ForEach-Object { $_.Groups[1].Value } | Sort-Object { [version]$_ } -Descending + } catch { return @() } +} + +function Install-PECLExt ([string]$extName, [string]$requestedVer = "") { + $info = Get-PHPBuildInfo + Write-Step "PHP $($info.Version) [$($info.TS) / $($info.VS) / $($info.Arch)]" + + $dllDest = "$($info.ExtDir)\php_$extName.dll" + if (Test-Path $dllDest) { + Write-Warn "php_$extName.dll already installed. Run: phpvm ext enable $extName" + return + } + + Write-Step "Fetching available versions for '$extName' ..." + $versions = Get-PECLVersions $extName + if (-not $versions) { + Write-Err "Extension '$extName' not found on windows.php.net/downloads/pecl/releases/" + Write-Dim "Browse: https://windows.php.net/downloads/pecl/releases/" + return + } + + $tryVersions = if ($requestedVer) { @($requestedVer) } else { $versions | Select-Object -First 5 } + $phpShort = $info.Short + $ts = $info.TS + $vs = $info.VS + $arch = $info.Arch + + $foundUrl = $null + $foundZip = $null + + :outer foreach ($ver in $tryVersions) { + $base = "https://windows.php.net/downloads/pecl/releases/$extName/$ver" + foreach ($candidate in @( + "php_$extName-$ver-$phpShort-$ts-$vs-$arch.zip" + "php_$extName-$ver-$phpShort-nts-$vs-$arch.zip" + "php_$extName-$ver-$phpShort-ts-$vs-$arch.zip" + )) { + if (Test-URLExists "$base/$candidate") { + $foundUrl = "$base/$candidate" + $foundZip = $candidate + break outer + } + } + } + + if (-not $foundUrl) { + Write-Err "No compatible package found for: $extName (PHP $phpShort $ts $vs $arch)" + Write-Dim "Browse: https://windows.php.net/downloads/pecl/releases/$extName/" + return + } + + $tempZip = "$env:TEMP\phpvm-pecl-$extName.zip" + $tempExtract = "$env:TEMP\phpvm-pecl-$extName" + + Write-Step "Downloading $foundZip ..." + Invoke-Download $foundUrl $tempZip + + Unblock-PHPVMPath $tempZip + + Write-Step "Extracting ..." + if (Test-Path $tempExtract) { Remove-Item $tempExtract -Recurse -Force } + Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force + Unblock-PHPVMPath $tempExtract + + $dll = Get-ChildItem $tempExtract -Filter "php_$extName.dll" -Recurse | Select-Object -First 1 + if (-not $dll) { Write-Err "php_$extName.dll not found in archive."; return } + + Copy-Item $dll.FullName $dllDest -Force + Unblock-PHPVMPath $dllDest + Write-Ok "Installed: php_$extName.dll" + + # Dependency DLLs go next to php.exe (must be on PATH at load time). + $phpRoot = Split-Path $info.Exe -Parent + Get-ChildItem $tempExtract -Filter "*.dll" | + Where-Object { $_.Name -ne "php_$extName.dll" } | + ForEach-Object { + $dep = "$phpRoot\$($_.Name)" + if (-not (Test-Path $dep)) { + Copy-Item $_.FullName $dep -Force + Unblock-PHPVMPath $dep + Write-Dim "Dependency: $($_.Name) -> PHP root" + } + } + + Remove-Item $tempZip, $tempExtract -Recurse -Force -ErrorAction SilentlyContinue + Write-Ok "Done. Enable with: phpvm ext enable $extName" + + Show-ExtRuntimeNotes $extName +} + +# Post-install runtime advisories for extensions that need extra system components. +function Show-ExtRuntimeNotes ([string]$extName) { + switch -Regex ($extName.ToLower()) { + '^(sqlsrv|pdo_sqlsrv)$' { + Write-Host "" + Write-Dim "Note: sqlsrv / pdo_sqlsrv also requires the Microsoft ODBC Driver" + Write-Dim "for SQL Server on this machine. Install (one-off, system-wide):" + Write-Dim " https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server" + Write-Dim "Setup guide: https://learn.microsoft.com/sql/connect/php/step-1-configure-development-environment-for-php-development" + } + } +} + +function Install-XDebug { + $info = Get-PHPBuildInfo + $dllDest = "$($info.ExtDir)\php_xdebug.dll" + + if (Test-Path $dllDest) { Write-Warn "XDebug already installed."; return } + + $phpShort = $info.Short + $vs = $info.VS + $ts = $info.TS + $archSuffix = if ($ts -eq "nts") { "nts-x86_64" } else { "x86_64" } + + Write-Step "Fetching XDebug for PHP $phpShort [$ts / $vs] from xdebug.org ..." + + try { + $html = Get-WebString "https://xdebug.org/files/" + $pattern = "php_xdebug-([\d.]+)-$phpShort-$vs-$archSuffix\.dll" + $hits = [regex]::Matches($html, $pattern) + + if (-not $hits.Count) { + $archSuffix = if ($ts -eq "ts") { "nts-x86_64" } else { "x86_64" } + $pattern = "php_xdebug-([\d.]+)-$phpShort-$vs-$archSuffix\.dll" + $hits = [regex]::Matches($html, $pattern) + } + + if (-not $hits.Count) { + Write-Err "No XDebug DLL found for PHP $phpShort / $vs." + Write-Dim "Use the wizard: https://xdebug.org/wizard" + return + } + + $xdVer = ($hits | ForEach-Object { $_.Groups[1].Value } | Sort-Object { [version]$_ } -Descending | Select-Object -First 1) + $dllName = "php_xdebug-$xdVer-$phpShort-$vs-$archSuffix.dll" + $url = "https://xdebug.org/files/$dllName" + } catch { + Write-Err "Failed to reach xdebug.org: $_" + Write-Dim "Manual: https://xdebug.org/wizard" + return + } + + Write-Step "Downloading XDebug $xdVer ..." + $tempDll = "$env:TEMP\$dllName" + Invoke-Download $url $tempDll + + if (-not $env:PHPVM_SKIP_HASH) { + Write-Step "Verifying SHA-256 ..." + $expected = Get-XDebugHash $url + if ($expected) { + $actual = (Get-FileHash -Path $tempDll -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + Write-Err "SHA-256 mismatch! Aborting." + Write-Dim " expected: $expected" + Write-Dim " actual: $actual" + Remove-Item $tempDll -Force + return + } + Write-Ok "SHA-256 verified." + } else { + Write-Warn "No SHA-256 published for $dllName; continuing unverified." + } + } + + Unblock-PHPVMPath $tempDll + Copy-Item $tempDll $dllDest -Force + Unblock-PHPVMPath $dllDest + Remove-Item $tempDll -Force + + $iniPath = $info.IniPath + if ($iniPath -and (Test-Path $iniPath)) { + $existing = Get-Content $iniPath -Raw + if ($existing -notmatch "(?m)^\s*zend_extension\s*=\s*xdebug") { + $block = @" + +[xdebug] +zend_extension=xdebug +xdebug.mode=debug +xdebug.start_with_request=yes +xdebug.client_host=127.0.0.1 +xdebug.client_port=9003 +"@ + Add-Content $iniPath $block + Write-Ok "XDebug config added to php.ini" + } + } + + Write-Ok "XDebug $xdVer installed and enabled!" + Write-Dim "VSCode: install 'PHP Debug' extension | listen on port 9003" +} + +function Ext-Info ([string]$extName) { + $info = Get-PHPBuildInfo + Write-Host "" + $out = & $info.Exe -r @" +if (extension_loaded('$extName')) { + `$r = new ReflectionExtension('$extName'); + echo 'Name : ' . `$r->getName() . PHP_EOL; + echo 'Version : ' . (`$r->getVersion() ?? 'n/a') . PHP_EOL; + `$classes = `$r->getClassNames(); + if (`$classes) echo 'Classes : ' . implode(', ', `$classes) . PHP_EOL; +} else { + echo "Not loaded. Run: phpvm ext enable $extName" . PHP_EOL; +} +"@ 2>$null + $out | ForEach-Object { Write-Host " $_" } + Write-Host "" +} + +function Ext-Laravel ([string]$preset = "full") { + $info = Get-PHPBuildInfo + + # Shipped with PHP - only need enable in php.ini. + $bundledMinimal = @( + "openssl" # HTTPS, encryption, queue + "pdo" # database abstraction + "pdo_mysql" # MySQL / MariaDB + "pdo_sqlite" # SQLite (testing) + "mbstring" # multibyte string, validation + "tokenizer" # Blade template parsing + "xml" # XML processing + "ctype" # character validation + "fileinfo" # MIME type detection (file upload) + "bcmath" # decimal precision (payments) + "curl" # HTTP client (Guzzle, APIs) + "zip" # compress/extract + "sodium" # encryption (Laravel Crypt) + ) + + $bundledFull = @( + "intl" # internationalisation, number/date formatting + "gd" # image manipulation (resize, thumbnail) + "exif" # read EXIF metadata from photos + "opcache" # bytecode cache - required in production + "pdo_pgsql" # PostgreSQL driver + "pgsql" # PostgreSQL native functions + "sockets" # Laravel Reverb / WebSocket / queue worker + ) + + # Need PECL download + enable. + $peclFull = @( + "redis" # Redis cache, session, queue driver + ) + + $enableList = $bundledMinimal + $peclList = @() + + if ($preset -ne "minimal") { + $enableList += $bundledFull + $peclList += $peclFull + } + + # -- Banner ------------------------------------------------ + $label = if ($preset -eq "minimal") { "minimal" } else { "full" } + Write-Host "" + Write-Host " Laravel extension setup ($label) - PHP $($info.Version)" -ForegroundColor Cyan + Write-Host " -----------------------------------------------------" -ForegroundColor DarkGray + Write-Host "" + + # -- Step 1: Enable bundled extensions -------------------- + Write-Host " [1/2] Enabling bundled extensions ..." -ForegroundColor Yellow + $extDir = $info.ExtDir + + # Snapshot once - Edit-IniExtension doesn't reload PHP. + $loaded = (& $info.Exe -m 2>$null) | ForEach-Object { $_.Trim().ToLower() } + + foreach ($ext in $enableList) { + $dllPath = "$extDir\php_$ext.dll" + + if (-not (Test-Path $dllPath)) { + Write-Host " skip $ext (DLL not found in this PHP build)" -ForegroundColor DarkGray + continue + } + + if ($loaded -contains $ext.ToLower()) { + Write-Host (" {0,-18} already ON" -f $ext) -ForegroundColor DarkGray + } else { + Edit-IniExtension $ext $true + } + } + + # -- Step 2: PECL extensions ------------------------------- + if ($peclList.Count -gt 0) { + Write-Host "" + Write-Host " [2/2] Installing PECL extensions ..." -ForegroundColor Yellow + foreach ($ext in $peclList) { + $dllPath = "$extDir\php_$ext.dll" + if (Test-Path $dllPath) { + if ($loaded -contains $ext.ToLower()) { + Write-Host (" {0,-18} already ON" -f $ext) -ForegroundColor DarkGray + } else { + Write-Host " $ext (DLL exists, enabling ...)" -ForegroundColor Cyan + Edit-IniExtension $ext $true + } + } else { + Install-PECLExt $ext + Edit-IniExtension $ext $true + } + } + } + + # -- Summary ----------------------------------------------- + Write-Host "" + Write-Ok "Done! Restart your terminal then verify with: php -m" + Write-Host "" + + if ($preset -eq "minimal") { + Write-Dim "For Redis + GD + opcache + intl, run: phpvm ext laravel full" + } else { + Write-Dim "Optional extras:" + Write-Dim " phpvm ext install xdebug # debugger" + Write-Dim " phpvm ext install imagick # advanced image processing" + Write-Dim " phpvm ext enable pdo_pgsql # if using PostgreSQL" + Write-Dim " phpvm composer # install Composer" + } + Write-Host "" +} + +function Invoke-Ext ([string]$sub, [string]$name, [string]$ver = "") { + switch ($sub.ToLower()) { + { $_ -in "list", "ls" } { Ext-List } + "loaded" { Ext-Loaded } + "enable" { if ($name) { Edit-IniExtension $name $true } else { Write-Err "Usage: phpvm ext enable " } } + "disable" { if ($name) { Edit-IniExtension $name $false } else { Write-Err "Usage: phpvm ext disable " } } + "install" { + if (-not $name) { Write-Err "Usage: phpvm ext install [version]"; return } + if ($name.ToLower() -eq "xdebug") { Install-XDebug } + else { Install-PECLExt $name $ver } + } + "info" { if ($name) { Ext-Info $name } else { Write-Err "Usage: phpvm ext info " } } + "laravel" { Ext-Laravel $name } + default { Show-ExtHelp } + } +} + +# ============================================================================== +# HELP +# ============================================================================== + +function Show-ExtHelp { + Write-Host @" + + phpvm ext - Extension Manager + --------------------------------------------------------- + + phpvm ext list Bundled extensions (ON/OFF) + phpvm ext loaded Loaded extensions (php -m) + phpvm ext enable Enable a bundled extension + phpvm ext disable Disable an extension + phpvm ext install Install PECL extension + phpvm ext install Install specific PECL version + phpvm ext install xdebug Install XDebug (xdebug.org) + phpvm ext info Extension details + phpvm ext laravel Enable all Laravel extensions (full) + phpvm ext laravel minimal Enable only required Laravel extensions + phpvm ext laravel full Enable required + recommended + Redis + + Common extensions: + phpvm ext enable mbstring phpvm ext enable curl + phpvm ext enable pdo_mysql phpvm ext enable zip + phpvm ext install redis phpvm ext install imagick + phpvm ext install xdebug phpvm ext install mongodb + +"@ -ForegroundColor Cyan +} diff --git a/windows/src/70-composer.ps1 b/windows/src/70-composer.ps1 new file mode 100644 index 0000000..5fd92ec --- /dev/null +++ b/windows/src/70-composer.ps1 @@ -0,0 +1,77 @@ + +function Invoke-Composer { + $info = Get-PHPBuildInfo + $loaded = (& $info.Exe -m 2>$null) | ForEach-Object { $_.Trim().ToLower() } + if ($loaded -notcontains "openssl") { + Write-Step "Enabling openssl extension (required for Composer) ..." + Edit-IniExtension "openssl" $true + Write-Warn "openssl enabled. If Composer install fails, restart terminal first then re-run 'phpvm composer'." + } + + # One global composer that follows the active PHP version: the phar lives in + # $PHPVM_DIR and the shim sits in $PHPVM_BIN (already on PATH) and calls + # whatever `php` resolves to. + $composerPhar = "$PHPVM_DIR\composer.phar" + $composerBat = "$PHPVM_BIN\composer.bat" + + if (Test-Path $composerBat) { + Write-Warn "Composer already installed at $composerBat" + Write-Dim "It follows your active PHP version automatically." + Write-Dim "Run: composer --version" + return + } + + $installerUrl = "https://getcomposer.org/installer" + $installerFile = "$env:TEMP\composer-setup.php" + $sigUrl = "https://composer.github.io/installer.sig" + + Write-Step "Downloading Composer installer ..." + try { + $ProgressPreference = "SilentlyContinue" + Invoke-WebRequest -Uri $installerUrl -OutFile $installerFile -UseBasicParsing + $expectedHash = (Get-WebString $sigUrl).Trim() + } catch { + Write-Err "Download failed: $_" + return + } + + Write-Step "Verifying installer integrity ..." + $actualHash = (& $info.Exe -r "echo hash_file('sha384', '$($installerFile -replace '\\','\\\\')');") + if ($actualHash -ne $expectedHash) { + Write-Err "Hash mismatch! Installer may be corrupt or tampered." + Remove-Item $installerFile -Force + return + } + Write-Ok "Hash verified." + + Write-Step "Installing Composer ..." + if (-not (Test-Path $PHPVM_BIN)) { New-Item -ItemType Directory -Path $PHPVM_BIN -Force | Out-Null } + Push-Location $PHPVM_DIR + & $info.Exe $installerFile --quiet --filename composer.phar + Pop-Location + + if (-not (Test-Path $composerPhar)) { + Write-Err "composer.phar not created. Check PHP error output above." + Remove-Item $installerFile -Force + return + } + + Remove-Item $installerFile -Force + + # Shim in $PHPVM_BIN (on PATH) calls `php` from PATH - i.e. the active + # version - so composer follows `phpvm use` without reinstalling. + $bat = @" +@echo off +php "$composerPhar" %* +"@ + $bat | Set-Content $composerBat -Encoding ASCII + Write-Ok "Composer installed (global)!" + Write-Ok " phar : $composerPhar" + Write-Ok " shim : $composerBat" + Write-Host "" + # 2>$null: composer writes its PHP-version banner and the "run diagnose" hint + # to stderr, which would bypass this pipeline and print unindented. + & $info.Exe $composerPhar --version 2>$null | ForEach-Object { Write-Host " $_" } + Write-Host "" + Write-Dim "Composer follows your active PHP version - no need to re-run after 'phpvm use'." +} diff --git a/windows/src/72-wpcli.ps1 b/windows/src/72-wpcli.ps1 new file mode 100644 index 0000000..50a622d --- /dev/null +++ b/windows/src/72-wpcli.ps1 @@ -0,0 +1,54 @@ +function Invoke-WpCli { + $info = Get-PHPBuildInfo + + # Same global-phar-plus-shim shape as Composer: phar in $PHPVM_DIR, shim in + # $PHPVM_BIN calls whatever `php` resolves to, so wp follows `phpvm use`. + $wpPhar = "$PHPVM_DIR\wp-cli.phar" + $wpBat = "$PHPVM_BIN\wp.bat" + + if (Test-Path $wpBat) { + Write-Warn "WP-CLI already installed at $wpBat" + Write-Dim "It follows your active PHP version automatically." + Write-Dim "Run: wp --version" + return + } + + $pharUrl = "https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar" + $hashUrl = "$pharUrl.sha512" + + Write-Step "Downloading WP-CLI ..." + try { + $ProgressPreference = "SilentlyContinue" + Invoke-WebRequest -Uri $pharUrl -OutFile $wpPhar -UseBasicParsing + } catch { + Write-Err "Download failed: $_" + return + } + + if (-not $env:PHPVM_SKIP_HASH) { + Write-Step "Verifying SHA-512 ..." + try { $expectedHash = ((Get-WebString $hashUrl).Trim() -split '\s+')[0] } + catch { Write-Err "Could not fetch checksum: $_"; Remove-Item $wpPhar -Force; return } + $actualHash = (& $info.Exe -r "echo hash_file('sha512', '$($wpPhar -replace '\\','\\\\')');") + if ($actualHash -ne $expectedHash) { + Write-Err "SHA-512 mismatch! Phar may be corrupt or tampered." + Remove-Item $wpPhar -Force + return + } + Write-Ok "SHA-512 verified." + } + + if (-not (Test-Path $PHPVM_BIN)) { New-Item -ItemType Directory -Path $PHPVM_BIN -Force | Out-Null } + $bat = @" +@echo off +php "$wpPhar" %* +"@ + $bat | Set-Content $wpBat -Encoding ASCII + Write-Ok "WP-CLI installed (global)!" + Write-Ok " phar : $wpPhar" + Write-Ok " shim : $wpBat" + Write-Host "" + & $info.Exe $wpPhar --version 2>$null | ForEach-Object { Write-Host " $_" } + Write-Host "" + Write-Dim "WP-CLI follows your active PHP version - no need to re-run after 'phpvm use'." +} diff --git a/windows/src/80-maint.ps1 b/windows/src/80-maint.ps1 new file mode 100644 index 0000000..2fe7b4c --- /dev/null +++ b/windows/src/80-maint.ps1 @@ -0,0 +1,196 @@ +function Invoke-FixIni { + $cur = Get-CurrentVersion + if (-not $cur) { Write-Err "No active PHP version. Run: phpvm use "; return } + + $targetDir = "$VERSIONS_DIR\$cur" + $ini = "$targetDir\php.ini" + $extPath = "$targetDir\ext" + + if (-not (Test-Path $ini)) { Write-Err "php.ini not found: $ini"; return } + + $before = Get-Content $ini -Raw + $content = $before -replace '(?m)^;?\s*extension_dir\s*=.*$', "extension_dir = `"$extPath`"" + + if ($content -eq $before) { + Write-Warn "extension_dir already correct or not found in php.ini." + } else { + $content | Set-Content $ini -NoNewline + Write-Ok "Fixed extension_dir -> $extPath" + } + + # Append if extension_dir was missing entirely. + if ($content -notmatch 'extension_dir\s*=') { + Add-Content $ini "`nextension_dir = `"$extPath`"" + Write-Ok "Added extension_dir -> $extPath" + } + + # Repair the CA bundle wiring too - fixes cURL error 60 on installs that + # predate the shared bundle. + $bundle = Get-CABundle + if ($bundle -and (Update-IniCACert $ini $bundle)) { + Write-Ok "CA bundle configured (curl.cainfo / openssl.cafile)." + } + + Write-Dim "Verify: phpvm ext list" +} + +# phpvm cacert [status|update] - manage the shared CA bundle. +function Invoke-Cacert ([string]$sub) { + switch ($sub.ToLower()) { + "update" { + $bundle = Get-CABundle -Force + if (-not $bundle) { return } + $cur = Get-CurrentVersion + if ($cur) { + if (Update-IniCACert "$VERSIONS_DIR\$cur\php.ini" $bundle) { + Write-Ok "Active php.ini points at the refreshed bundle." + } + } + } + { $_ -in "", "status" } { + if (Test-Path $PHPVM_CACERT) { + $age = [int]((Get-Date) - (Get-Item $PHPVM_CACERT).LastWriteTime).TotalDays + Write-Ok "CA bundle: $PHPVM_CACERT (updated $age day(s) ago)" + Write-Dim "Refresh with: phpvm cacert update" + } else { + Write-Warn "No CA bundle yet. Run: phpvm cacert update" + } + } + default { + Write-Err "Usage: phpvm cacert [status|update]" + } + } +} + + +# True when the ini's extension_dir points at the active version's ext folder. +# `current` is a junction to versions\, so the versions\\ext and +# current\ext spellings name the same directory - accept either rather than +# string-comparing and false-flagging a valid setup. +function Test-ExtDirMatch ([string]$iniExtDir, [string]$cur) { + if (-not $iniExtDir) { return $false } + $acceptable = @("$VERSIONS_DIR\$cur\ext", "$CURRENT_LINK\ext") | + ForEach-Object { $_.TrimEnd('\') } + return ($acceptable -icontains $iniExtDir.TrimEnd('\')) +} + +# Read-only health check. Never mutates state - every finding points at the +# command that fixes it. Exit-code-neutral: it's a report, not a gate. +function Invoke-Doctor { + Write-Host "" + Write-Host " phpvm doctor - environment health check" -ForegroundColor Cyan + Write-Host " ---------------------------------------------------------" -ForegroundColor Cyan + + function Doctor-Ok ($m) { Write-Host " [ok] $m" -ForegroundColor Green; $script:__docOk++ } + function Doctor-Warn ($m) { Write-Host " [warn] $m" -ForegroundColor Yellow; $script:__docWarn++ } + $script:__docOk = 0; $script:__docWarn = 0 + + # 1. Active version + junction health. + $cur = Get-CurrentVersion + if ($cur) { + Doctor-Ok "Active PHP version: $cur" + } else { + Doctor-Warn "No active PHP version. Run: phpvm use " + } + + # 2. PATH shadowing: whichever php.exe resolves first is what runs. If it + # isn't phpvm's, a XAMPP/Laragon/system PHP is winning. + $phpSources = @(Get-Command php.exe -All -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }) + if ($phpSources.Count -eq 0) { + Doctor-Warn "No 'php' found on PATH. Open a new terminal after 'phpvm use', or check PATH." + } else { + $first = $phpSources[0] + if ($first -like "$CURRENT_LINK*" -or $first -like "$PHPVM_BIN*" -or $first -like "$VERSIONS_DIR*") { + Doctor-Ok "'php' resolves to phpvm: $first" + } else { + Doctor-Warn "'php' resolves to a non-phpvm install: $first" + Write-Dim "phpvm's bin must come first on PATH. Open a new terminal after 'phpvm use'." + } + $conflict = $phpSources | Where-Object { $_ -match '(?i)xampp|laragon|wamp' } | Select-Object -First 1 + if ($conflict) { + Doctor-Warn "Another PHP toolchain on PATH: $conflict" + Write-Dim "XAMPP/Laragon/WAMP can shadow phpvm. Remove it from PATH or reorder." + } + } + + # 3. ext_dir mismatch: php.ini's extension_dir must match the active build's + # ext folder, or bundled extensions silently fail to load. + if ($cur) { + try { + $info = Get-PHPBuildInfo + if ($info.IniPath -and (Test-Path $info.IniPath)) { + $iniExtDir = Invoke-PHP $info.Exe "echo ini_get('extension_dir');" + if (Test-ExtDirMatch $iniExtDir $cur) { + Doctor-Ok "extension_dir matches active build." + } else { + Doctor-Warn "extension_dir mismatch: '$iniExtDir' != '$($info.ExtDir)'" + Write-Dim "Fix with: phpvm fix-ini" + } + } else { + Doctor-Warn "No php.ini loaded for the active version." + Write-Dim "Fix with: phpvm fix-ini" + } + } catch { + Doctor-Warn "Could not read active PHP build info: $_" + } + } + + # 4. CA bundle (HTTPS/TLS for composer, ext downloads). + if (Test-Path $PHPVM_CACERT) { + $age = [int]((Get-Date) - (Get-Item $PHPVM_CACERT).LastWriteTime).TotalDays + Doctor-Ok "CA bundle present ($age day(s) old)." + } else { + Doctor-Warn "No CA bundle. Run: phpvm cacert update" + } + + # 5. VC++ runtime: prebuilt PHP (vs16/vs17) needs the VC++ 2015-2022 redist. + if (Test-Path "$env:SystemRoot\System32\vcruntime140.dll") { + Doctor-Ok "VC++ runtime (vcruntime140.dll) present." + } else { + Doctor-Warn "VC++ runtime not found. PHP may fail to start." + Write-Dim "Install: https://aka.ms/vs/17/release/vc_redist.x64.exe" + } + + Write-Host "" + if ($script:__docWarn -eq 0) { + Write-Ok "All checks passed ($script:__docOk ok)." + } else { + Write-Warn "$script:__docWarn warning(s), $script:__docOk ok. See fixes above." + } + Write-Host "" +} + +function Invoke-Upgrade { + $scriptUrl = "https://raw.githubusercontent.com/devhardiyanto/phpvm/main/windows/phpvm.ps1" + $versionUrl = "https://raw.githubusercontent.com/devhardiyanto/phpvm/main/version.txt" + $scriptDest = "$PHPVM_DIR\phpvm.ps1" + + Write-Step "Checking latest version ..." + try { + $latest = (Get-WebString $versionUrl 5).Trim() + } catch { + Write-Err "Could not reach GitHub. Check your connection." + return + } + + if ([version]$latest -le [version]$PHPVM_VERSION) { + Write-Ok "Already up to date. (phpvm $PHPVM_VERSION)" + return + } + + Write-Step "Upgrading phpvm $PHPVM_VERSION -> $latest ..." + + $backup = "$PHPVM_DIR\phpvm.ps1.bak" + Copy-Item $scriptDest $backup -Force + Write-Dim "Backup saved: $backup" + + try { + Invoke-WebRequest -Uri $scriptUrl -OutFile $scriptDest -UseBasicParsing + Unblock-File $scriptDest + Write-Ok "phpvm upgraded to $latest!" + } catch { + Write-Err "Upgrade failed: $_" + Copy-Item $backup $scriptDest -Force + Write-Warn "Rolled back to previous version." + } +} diff --git a/windows/src/90-help.ps1 b/windows/src/90-help.ps1 new file mode 100644 index 0000000..4c76920 --- /dev/null +++ b/windows/src/90-help.ps1 @@ -0,0 +1,97 @@ +function Show-Help { + Write-Host @" + + phpvm $PHPVM_VERSION - PHP Version Manager for Windows + --------------------------------------------------------- + + VERSION MANAGEMENT + phpvm install Download & install a PHP version + --no-use install without switching to it + --no-cacert skip CA bundle configuration + 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.exe + phpvm ini Open active php.ini in Notepad + phpvm fix-ini Sync extension_dir & CA bundle in active php.ini + phpvm cacert [status|update] Manage the shared CA bundle (HTTPS/TLS) + phpvm doctor Diagnose PATH, ext_dir, CA bundle, VC++ runtime + + 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 (PowerShell prompt hook) + 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 Show all bundled extensions + phpvm ext enable Enable a bundled extension + phpvm ext install Install from PECL / xdebug.org + phpvm ext help Full extension reference (list, loaded, + disable, info, laravel, examples) + + EXAMPLES + phpvm install 8.3.0 + phpvm install 8.1.29 + phpvm use 8.3.0 + phpvm ext enable mbstring + phpvm ext enable pdo_mysql + phpvm ext install redis + phpvm ext install xdebug + + Home: $PHPVM_DIR + +"@ -ForegroundColor Cyan +} + +# -- Did-you-mean (unknown command handling) ----------------------------------- +# Iterative Levenshtein distance (two-row, O(n) memory). +function Get-Levenshtein ([string]$a, [string]$b) { + $la = $a.Length; $lb = $b.Length + if ($la -eq 0) { return $lb } + if ($lb -eq 0) { return $la } + $row = 0..$lb + for ($i = 1; $i -le $la; $i++) { + $prev = $row[0] + $row[0] = $i + for ($j = 1; $j -le $lb; $j++) { + $cur = $row[$j] + $cost = if ($a[$i - 1] -eq $b[$j - 1]) { 0 } else { 1 } + $del = $row[$j] + 1 + $ins = $row[$j - 1] + 1 + $sub = $prev + $cost + $row[$j] = [Math]::Min([Math]::Min($del, $ins), $sub) + $prev = $cur + } + } + return $row[$lb] +} + +# Unknown command: suggest the nearest match instead of dumping the full help. +function Invoke-Unknown ([string]$cmd) { + $cmds = @("install","use","list","ls","current","uninstall","remove", + "which","ini","fix-ini","cacert","doctor","ext","composer","wp-cli","auto","hook", + "upgrade","update","version","help") + $best = ""; $bestd = 99 + foreach ($c in $cmds) { + $d = Get-Levenshtein $cmd.ToLower() $c + if ($d -lt $bestd) { $bestd = $d; $best = $c } + } + Write-Err "'$cmd' is not a phpvm command." + if ($bestd -le 2) { Write-Dim "Did you mean '$best'?" } + Write-Dim "Run 'phpvm help' to see all commands." +} diff --git a/windows/src/99-entry.ps1 b/windows/src/99-entry.ps1 new file mode 100644 index 0000000..b54f32e --- /dev/null +++ b/windows/src/99-entry.ps1 @@ -0,0 +1,32 @@ +# Tests dot-source this file and set $env:PHPVM_NO_ENTRY=1 to skip the entry point. +if (-not $env:PHPVM_NO_ENTRY) { + Initialize-PHPVM + + $skipUpdateFor = @("", "help", "--help", "version", "-v", "list", "ls", "current", "which", "ini", "auto", "hook") + if ($Command.ToLower() -notin $skipUpdateFor) { + Check-PHPVMUpdate + } + + switch ($Command.ToLower()) { + "install" { Invoke-Install $SubOrVer $Arg2 } + "use" { Invoke-Use $SubOrVer } + { $_ -in "list", "ls" } { Invoke-List } + "current" { Invoke-Current } + { $_ -in "uninstall", "remove" }{ Invoke-Uninstall $SubOrVer } + "which" { Invoke-Which } + "ini" { Invoke-Ini } + "fix-ini" { Invoke-FixIni } + "cacert" { Invoke-Cacert $SubOrVer } + "doctor" { Invoke-Doctor } + "ext" { Invoke-Ext $SubOrVer $Arg2 $Arg3 } + "auto" { Invoke-Auto } + "hook" { Invoke-Hook $SubOrVer } + "composer" { Invoke-Composer } + "wp-cli" { Invoke-WpCli } + { $_ -in "upgrade", "update" } { Invoke-Upgrade } + { $_ -in "version", "-v" } { Write-Ok "phpvm $PHPVM_VERSION" } + { $_ -in "help", "--help" } { Show-Help } + "" { Show-Help } + default { Invoke-Unknown $Command } + } +} From 402baa4510205d2f1a3253f4b942b01b091c5c39 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:48:30 +0700 Subject: [PATCH 8/8] chore: bump version to 1.13.0 devhardiyanto --- linux/install.sh | 2 +- linux/phpvm.sh | 2 +- version.txt | 2 +- windows/install.ps1 | 2 +- windows/phpvm.ps1 | 2 +- windows/src/00-header.ps1 | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/linux/install.sh b/linux/install.sh index 3188778..c4c20ae 100644 --- a/linux/install.sh +++ b/linux/install.sh @@ -6,7 +6,7 @@ set -e -PHPVM_VERSION="1.12.4" +PHPVM_VERSION="1.13.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 2715cbe..fc6b0b5 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -10,7 +10,7 @@ # phpvm use 8.3.0 # ============================================================================== -PHPVM_VERSION="1.12.4" +PHPVM_VERSION="1.13.0" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_VERSIONS="$PHPVM_DIR/versions" PHPVM_CURRENT="$PHPVM_DIR/current" diff --git a/version.txt b/version.txt index 44fdbc3..f88cf52 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.12.4 \ No newline at end of file +1.13.0 \ No newline at end of file diff --git a/windows/install.ps1 b/windows/install.ps1 index 45c2eba..ea7166e 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -8,7 +8,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$PHPVM_VERSION = "1.12.4" +$PHPVM_VERSION = "1.13.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 4cc63ea..c389391 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -23,7 +23,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.12.4" +$PHPVM_VERSION = "1.13.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 ce6e2cf..52f0bfd 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.12.4" +$PHPVM_VERSION = "1.13.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current"