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"