From 764a6489c6fb00e8cb48335942ffd91092479236 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Mon, 8 Jun 2026 12:34:07 +1000 Subject: [PATCH 01/25] =?UTF-8?q?feat:=20harden=20asset=20resolution=20?= =?UTF-8?q?=E2=80=94=20fallback=20URLs,=20retry,=20offline=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pinned fallback URLs for Apache Lounge & phpMyAdmin when live HTML scraping fails (page restructures, server downtime) - Replace MariaDB HttpWebRequest redirect chain with native Invoke-WebRequest -MaximumRedirection 0 (no namespace risk) - Add 3x retry loop to VC++ Redistributable direct download - Add -Offline switch: skip URL resolution and use pre-downloaded zips from $TEMP_DOWNLOADS - New Invoke-ExtractZip helper for offline extraction — assisted by Hymie (DaFa's AI) --- getphp.ps1 | 221 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 149 insertions(+), 72 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 4ff256a..1ef3cfa 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -12,6 +12,14 @@ # ---- Config ------------------------------------------------- $TEMP_DOWNLOADS = "$env:TEMP\webstack_downloads" +# Pinned fallback URLs — used when live scraping/API resolution fails. +# These point to the last known-good versions for each component. +# Update these when bumping the pinned versions. +$FALLBACK_URLS = @{ + Apache = "" + phpMyAdmin = "" +} + # ---- Colours ----------------------------------------------- function Write-Ok($msg) { Write-Host "[ OK ] $msg" -ForegroundColor Green } function Write-Err($msg) { Write-Host "[ Error ] $msg" -ForegroundColor Red } @@ -306,24 +314,45 @@ function Install-VcRedist { # Fallback: direct download (for systems without winget) $installer = "$env:TEMP\vc_redist.x64.exe" - try { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile $installer - Write-Info "Running installer (silent -- this may take a moment)..." - $proc = Start-Process -FilePath $installer -ArgumentList "/install", "/quiet", "/norestart" -Wait -PassThru - Remove-Item $installer -Force -ErrorAction SilentlyContinue - if ($proc.ExitCode -eq 0 -or $proc.ExitCode -eq 3010) { - Write-Ok "Visual C++ Redistributable installed successfully" + + $maxRetries = 3 + $retryDelay = 5 + $downloaded = $false + + for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { + try { + if ($attempt -gt 1) { + Write-Info " Retry $attempt of $maxRetries..." + } + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile $installer + $downloaded = $true + break } - else { - Write-Warn "Installer exited with code $($proc.ExitCode). Install manually:" - Write-Info " https://aka.ms/vs/17/release/vc_redist.x64.exe" + catch { + if ($attempt -lt $maxRetries) { + Write-Warn "Download attempt $attempt failed. Retrying in $retryDelay seconds..." + Start-Sleep -Seconds $retryDelay + } + else { + Write-Err "Failed to download VC++ Redistributable after $maxRetries attempts: $_" + Write-Info "Install manually: https://aka.ms/vs/17/release/vc_redist.x64.exe" + return + } } } - catch { - Write-Err "Failed to download or install VC++ Redistributable: $_" - Write-Info "Install manually: https://aka.ms/vs/17/release/vc_redist.x64.exe" - Remove-Item $installer -Force -ErrorAction SilentlyContinue + + if (-not $downloaded) { return } + + Write-Info "Running installer (silent -- this may take a moment)..." + $proc = Start-Process -FilePath $installer -ArgumentList "/install", "/quiet", "/norestart" -Wait -PassThru + Remove-Item $installer -Force -ErrorAction SilentlyContinue + if ($proc.ExitCode -eq 0 -or $proc.ExitCode -eq 3010) { + Write-Ok "Visual C++ Redistributable installed successfully" + } + else { + Write-Warn "Installer exited with code $($proc.ExitCode). Install manually:" + Write-Info " https://aka.ms/vs/17/release/vc_redist.x64.exe" } } @@ -379,8 +408,12 @@ function Get-LatestApacheUrl { Start-Sleep -Seconds $retryDelay } else { - Write-Err "Failed to resolve Apache URL after $maxRetries attempts." - Write-Info " Apache Lounge may be temporarily offline." + Write-Warn "Live resolution failed after $maxRetries attempts." + if ($FALLBACK_URLS.Apache) { + Write-Info " Falling back to pinned Apache URL: $($FALLBACK_URLS.Apache)" + return $FALLBACK_URLS.Apache + } + Write-Err "Failed to resolve Apache URL and no fallback URL is configured." Write-Info " Check https://www.apachelounge.com/ or try again later." throw } @@ -571,7 +604,12 @@ function Get-LatestPhpMyAdminUrl { Start-Sleep -Seconds $retryDelay } else { - Write-Err "Failed to resolve phpMyAdmin URL after $maxRetries attempts." + Write-Warn "Live resolution failed after $maxRetries attempts." + if ($FALLBACK_URLS.phpMyAdmin) { + Write-Info " Falling back to pinned phpMyAdmin URL: $($FALLBACK_URLS.phpMyAdmin)" + return $FALLBACK_URLS.phpMyAdmin + } + Write-Err "Failed to resolve phpMyAdmin URL and no fallback URL is configured." Write-Info " Check https://www.phpmyadmin.net/ or try again later." throw } @@ -604,62 +642,33 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { Write-Info " Retry $attempt of $maxRetries..." } - # MariaDB uses HTTP redirects that Invoke-WebRequest can't handle reliably. + # MariaDB download URLs redirect through a CDN chain that + # Invoke-WebRequest with auto-redirect sometimes mishandles. + # Follow redirects manually, then download the final URL. if ($url -like "*mariadb*") { $current_url = $url $max_redirects = 10 $i = 0 while ($i -lt $max_redirects) { - $request = [System.Net.HttpWebRequest]::Create($current_url) - $request.Method = "GET" - $request.AllowAutoRedirect = $false - $request.UserAgent = $ua - - $response = $request.GetResponse() + $response = Invoke-WebRequest -Uri $current_url -Method Get ` + -MaximumRedirection 0 -Headers @{ "User-Agent" = $ua } ` + -ErrorAction Stop $status = [int]$response.StatusCode if ($status -ge 300 -and $status -lt 400) { $location = $response.Headers["Location"] if (-not $location) { - $response.Close() throw "Redirect without Location header" } $current_url = $location - $response.Close() $i++ continue } - # Final URL reached — stream to file with progress - $totalBytes = $response.ContentLength - $stream = $null - $fileStream = $null - try { - $stream = $response.GetResponseStream() - $fileStream = [System.IO.File]::Create($zipPath) - $buffer = New-Object byte[] 8192 - $bytesRead = 0 - $totalRead = 0 - $lastReport = 0 - - while (($bytesRead = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { - $fileStream.Write($buffer, 0, $bytesRead) - $totalRead += $bytesRead - # Report progress every 1MB to avoid flooding - if ($totalBytes -gt 0 -and ($totalRead - $lastReport) -ge 1048576) { - $pct = [int](($totalRead / $totalBytes) * 100) - Write-Progress -Activity "Downloading $label" -Status "$([math]::Round($totalRead/1MB,1)) MB / $([math]::Round($totalBytes/1MB,1)) MB" -PercentComplete $pct - $lastReport = $totalRead - } - } - Write-Progress -Activity "Downloading $label" -Completed - } - finally { - if ($fileStream) { $fileStream.Close(); $fileStream.Dispose() } - if ($stream) { $stream.Close(); $stream.Dispose() } - $response.Close() - } + # Final URL — download with OutFile + Invoke-WebRequest -Uri $current_url -OutFile $zipPath ` + -Headers @{ "User-Agent" = $ua } break } @@ -741,6 +750,28 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { Write-Ok "$label extracted" } +# Offline-only: extract a pre-downloaded zip directly (no download step). +function Invoke-ExtractZip($zipPath, $dest, $label) { + Write-Host "" + Write-Info "Extracting $label from $zipPath..." + Expand-Archive -Path $zipPath -DestinationPath $dest -Force + + # Flatten wrapper folder if present + $allItems = @(Get-ChildItem $dest -Force) + $dirsOnly = @($allItems | Where-Object { $_ -is [System.IO.DirectoryInfo] }) + $filesOnly = @($allItems | Where-Object { $_ -is [System.IO.FileInfo] }) + + if ($dirsOnly.Count -eq 1 -and $filesOnly.Count -eq 0) { + $inner = $dirsOnly[0].FullName + Write-Info " Flattening wrapper folder: $($dirsOnly[0].Name)" + Get-ChildItem $inner -Force | ForEach-Object { + Move-Item $_.FullName $dest -Force -ErrorAction SilentlyContinue + } + Remove-Item $inner -Recurse -Force -ErrorAction SilentlyContinue + } + Write-Ok "$label extracted" +} + # ============================================================ # CONFIGURATION # ============================================================ @@ -1507,26 +1538,68 @@ function Invoke-InstallWebStack { New-Item -ItemType Directory -Force -Path $WWW_PATH | Out-Null New-Item -ItemType Directory -Force -Path $TEMP_DOWNLOADS | Out-Null - # Resolve URLs - Write-Host "" - Write-Bold "Resolving latest stable versions..." + # Resolve URLs (or use local zips in offline mode) Write-Host "" + if ($Offline) { + Write-Bold "Offline mode — using pre-downloaded zips from: $TEMP_DOWNLOADS" + Write-Host "" - try { - $apacheUrl = Get-LatestApacheUrl - $phpUrl = Get-LatestPhpUrl - $mariadbUrl = Get-LatestMariadbUrl - $pmaUrl = Get-LatestPhpMyAdminUrl - } - catch { - Write-Err "Failed to resolve one or more download URLs. Aborting." - return + $zipFiles = Get-ChildItem -Path $TEMP_DOWNLOADS -Filter "*.zip" -ErrorAction SilentlyContinue + if (-not $zipFiles -or $zipFiles.Count -lt 4) { + Write-Err "Offline mode requires 4 zip files in $TEMP_DOWNLOADS (Apache, PHP, MariaDB, phpMyAdmin)." + Write-Info " Run the script online once to download them, or place them manually." + return + } + + Write-Ok "Found $($zipFiles.Count) zip files — skipping URL resolution and download." + + foreach ($zip in $zipFiles) { + $name = $zip.BaseName.ToLower() + if ($name -like "*httpd*" -or $name -like "*apache*") { + $apacheZip = $zip.FullName + } + elseif ($name -like "*php-*" -and $name -notlike "*phpmyadmin*") { + $phpZip = $zip.FullName + } + elseif ($name -like "*mariadb*") { + $mariadbZip = $zip.FullName + } + elseif ($name -like "*phpmyadmin*") { + $pmaZip = $zip.FullName + } + } + + # Extract directly + if ($apacheZip) { Invoke-ExtractZip $apacheZip $APACHE_PATH "Apache" } + if ($phpZip) { Invoke-ExtractZip $phpZip $PHP_PATH "PHP" } + if ($mariadbZip) { Invoke-ExtractZip $mariadbZip $MARIADB_PATH "MariaDB" } + + if (-not $apacheZip -or -not $phpZip -or -not $mariadbZip) { + Write-Err "Could not identify all required zips by filename convention." + Write-Info " Expected: *httpd* or *apache*, *php-* (not phpmyadmin), *mariadb*, *phpmyadmin*" + return + } } + else { + Write-Bold "Resolving latest stable versions..." + Write-Host "" + + try { + $apacheUrl = Get-LatestApacheUrl + $phpUrl = Get-LatestPhpUrl + $mariadbUrl = Get-LatestMariadbUrl + $pmaUrl = Get-LatestPhpMyAdminUrl + } + catch { + Write-Err "Failed to resolve one or more download URLs. Aborting." + return + } - # Download and extract (Apache, PHP, MariaDB only — PMA deferred) - Invoke-DownloadAndExtract $apacheUrl $APACHE_PATH "Apache" - Invoke-DownloadAndExtract $phpUrl $PHP_PATH "PHP" - Invoke-DownloadAndExtract $mariadbUrl $MARIADB_PATH "MariaDB" + # Download and extract (Apache, PHP, MariaDB only — PMA deferred) + Invoke-DownloadAndExtract $apacheUrl $APACHE_PATH "Apache" + Invoke-DownloadAndExtract $phpUrl $PHP_PATH "PHP" + Invoke-DownloadAndExtract $mariadbUrl $MARIADB_PATH "MariaDB" + } # Copy PHP dependency DLLs to Apache bin (ICU, curl deps, etc.) # Windows DLL search starts from httpd.exe's directory, not PHP's. @@ -1996,6 +2069,10 @@ function Show-Dashboard { # MAIN LOOP # ============================================================ +param( + [switch]$Offline # Skip URL resolution — use pre-downloaded zips from TEMP +) + # Ensure we're running as Admin $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") From d1c376856148a245c4a68483c8cbccb146c4999f Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 11:58:08 +1000 Subject: [PATCH 02/25] feat: MariaDB download fixes, log consolidation, install UX improvements - MariaDB: construct direct archive URL instead of REST API redirector - MariaDB: add fallback to FALLBACK_URLS when REST API resolution fails - MariaDB: write my.ini with log-error path to logs/ directory - MariaDB: fix --log-error not taking effect (remove --console from Start-Process) - Download: remove broken manual redirect loop; use standard Invoke-WebRequest - Install: skip re-download when components already at latest version - Install: change default path from %APPDATA% to %USERPROFILE% - Install: move 'Installation Complete!' banner after services + phpMyAdmin storage - Logs: consolidate Apache/PHP/MariaDB logs into logs/ directory - UX: add getPHP ASCII banner at script start (shared with dashboard) - UX: add completion banner to delete flow - UX: spacing and progress message improvements - Docs: update README_Win.md with new paths, logs, and MariaDB changes --- README_Win.md | 40 +++++---- getphp.ps1 | 221 +++++++++++++++++++++++++++++--------------------- 2 files changed, 152 insertions(+), 109 deletions(-) diff --git a/README_Win.md b/README_Win.md index 23592ae..3aa4470 100644 --- a/README_Win.md +++ b/README_Win.md @@ -26,12 +26,12 @@ On subsequent runs the script remembers your install path and goes straight to t | **MariaDB** | [mariadb.org](https://downloads.mariadb.org/rest-api/mariadb/) | ✅ Queries REST API for latest Stable (Rolling > LTS) | | **phpMyAdmin** | [phpmyadmin.net](https://www.phpmyadmin.net/downloads/) | ✅ Scrapes downloads page for latest stable | -All installed to `C:\webstack\` by default — no system-wide changes, no cruft. Optionally register as Windows services for auto-start on boot. +All installed to `%USERPROFILE%\getphp\` by default (e.g. `C:\Users\\getphp`) — matching the home-directory convention of getphp.sh on Mac/Linux. No system-wide changes, no cruft. Optionally register as Windows services for auto-start on boot. ## Directory Layout ``` -C:\webstack\ +C:\Users\\getphp\ ├── apache\ # Apache Lounge (VS18, port 80) │ ├── bin\ │ ├── conf\ @@ -47,6 +47,11 @@ C:\webstack\ ├── www\ # ← Your websites go here │ ├── phpinfo.php # (auto-created test file) │ └── phpmyadmin\ # phpMyAdmin +├── logs\ # All log files +│ ├── apache_error.log +│ ├── apache_access.log +│ ├── php_errors.log +│ └── mariadb_error.log └── data_backup\ # (created on delete — databases preserved here) ``` @@ -106,7 +111,7 @@ Q Quit | Question | Answer | | --------------------------- | -------------------------------------- | -| Where to put website files? | `C:\webstack\www` | +| Where to put website files? | `%USERPROFILE%\getphp\www` | | How to test your PHP setup? | http://localhost/phpinfo.php | | Where to access phpMyAdmin? | http://localhost/phpmyadmin | | How to log into phpMyAdmin? | Username: `root` / Password: _(blank)_ | @@ -126,15 +131,16 @@ Example `config.json`: ```json { - "install_path": "C:\\webstack", + "install_path": "C:\\Users\\\\getphp", "installed_at": "2026-06-05T20:45:00", "services_registered": true, "paths": { - "apache": "C:\\webstack\\apache", - "php": "C:\\webstack\\php", - "mariadb": "C:\\webstack\\mariadb", - "www": "C:\\webstack\\www", - "phpmyadmin": "C:\\webstack\\www\\phpmyadmin" + "apache": "C:\\Users\\\\getphp\\apache", + "php": "C:\\Users\\\\getphp\\php", + "mariadb": "C:\\Users\\\\getphp\\mariadb", + "www": "C:\\Users\\\\getphp\\www", + "logs": "C:\\Users\\\\getphp\\logs", + "phpmyadmin": "C:\\Users\\\\getphp\\www\\phpmyadmin" }, "versions": { "apache": "2.4.67", @@ -142,7 +148,7 @@ Example `config.json`: "mariadb": "12.3.2", "phpmyadmin": "5.2.3" }, - "path_entries": ["C:\\webstack\\php", "C:\\webstack\\mariadb\\bin"] + "path_entries": ["C:\\Users\\\\getphp\\php", "C:\\Users\\\\getphp\\mariadb\\bin"] } ``` @@ -151,11 +157,11 @@ Example `config.json`: ### Apache - Port 80, ServerName `localhost:80` (suppresses AH00558 warnings) -- DocumentRoot `C:/webstack/www` with `Options Indexes FollowSymLinks` +- DocumentRoot with `Options Indexes FollowSymLinks` - `mod_rewrite` enabled with `AllowOverride All` — Trongate, Laravel, WordPress `.htaccess` rewrites work out of the box - PHP module loaded from the installed PHP path - phpMyAdmin alias at `/phpmyadmin` -- Error and access logs written to `www/` +- Error and access logs written to `logs/` (not `www/`) - Stale `httpd.pid` cleaned before each start (no "unclean shutdown" warnings) - Graceful shutdown via `httpd.exe -k stop` (force kill only as fallback) @@ -163,7 +169,7 @@ Example `config.json`: - **Extensions enabled:** `curl`, `fileinfo`, `gd`, `intl`, `mbstring`, `mysqli`, `openssl`, `pdo_mysql`, `pdo_sqlite`, `sqlite3` - `display_errors = On` for development -- **Error logging:** `error_log = C:/webstack/www/php_errors.log` +- **Error logging:** `error_log = logs/php_errors.log` - **OPCache:** Enabled with 256 MB memory, 16 MB interned strings, 20,000 files, JIT tracing with 100 MB buffer — production-ready out of the box - **DLL compatibility:** PHP dependency DLLs (ICU, libssh2, nghttp2, etc.) are automatically copied to Apache's `bin/` to resolve extension loading warnings under Windows DLL search order - **Added to user PATH** — `php` command works from any new terminal window @@ -176,8 +182,10 @@ Example `config.json`: ### MariaDB - Data directory initialised with blank root password +- `my.ini` written with `log-error` → `logs/mariadb_error.log` - Latest stable release resolved via REST API (Rolling > LTS) - Debug-symbols-only zip excluded from download filter +- Download URL constructed directly from archive (bypasses REST API redirector) - **Added to user PATH** — `mysql` command works from any new terminal window ### phpMyAdmin @@ -229,11 +237,11 @@ Services are automatically removed when you delete the stack (`D`). ## Zero Footprint -The `getphp.ps1` script runs entirely in-memory and never installs itself on your machine. Only the web stack is added to `C:\webstack\` if you choose to install it, plus a small config file at `%APPDATA%\getphp\config.json`. To manage services, update, or uninstall the stack, simply re-run the script at any time. +The `getphp.ps1` script runs entirely in-memory and never installs itself on your machine. Only the web stack is added to `%USERPROFILE%\getphp\` if you choose to install it, plus a small config file at `%APPDATA%\getphp\config.json`. To manage services, update, or uninstall the stack, simply re-run the script at any time. ## Uninstalling -Run the script and press **D** (Delete). This removes Apache, PHP, MariaDB, and phpMyAdmin but **preserves** your website files in `C:\webstack\www\` and your MariaDB data in `C:\webstack\data_backup\`. PATH entries are removed and the config is cleared. To perform a complete wipe, delete `C:\webstack\` and `%APPDATA%\getphp\` manually after running Delete. +Run the script and press **D** (Delete). This removes Apache, PHP, MariaDB, and phpMyAdmin but **preserves** your website files in `www\` and your MariaDB data in `data_backup\`. PATH entries are removed and the config is cleared. To perform a complete wipe, delete the install directory and `%APPDATA%\getphp\` manually after running Delete. ## How It Resolves Latest Versions @@ -241,7 +249,7 @@ Unlike most installers that hardcode version numbers, `getphp.ps1` dynamically r - **Apache** — Scrapes the Apache Lounge download page, finds all VS## x64 zips, picks the highest VS version × Apache version combination - **PHP** — Queries the `releases.json` API from windows.php.net, filters for PHP 8.x thread-safe x64, prefers VS17 builds over VS16 -- **MariaDB** — Queries the MariaDB REST API (`/rest-api/mariadb/`), sorts stable releases by support policy (Rolling > LTS), then by version number. Excludes debug-symbols-only zips. +- **MariaDB** — Queries the MariaDB REST API (`/rest-api/mariadb/`), sorts stable releases by support policy (Rolling > LTS), then by version number. Constructs direct archive URL from version and filename (bypasses REST API redirector). Excludes debug-symbols-only zips. - **phpMyAdmin** — Scrapes the phpMyAdmin downloads page, finds all stable `all-languages.zip` files (excluding snapshots), picks the highest version ## Known Quirks & Fixes diff --git a/getphp.ps1 b/getphp.ps1 index 1ef3cfa..2b93af9 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -4,20 +4,35 @@ # Github: https://github.com/getphporg/getphp # Author: Simon Field (aka - DaFa) # License: MIT -# Date: 2026-06-07 -# Version: 1.0.3 +# Date: 2026-06-13 +# Version: 1.0.4 # ============================================================ #Requires -RunAsAdministrator # ---- Config ------------------------------------------------- $TEMP_DOWNLOADS = "$env:TEMP\webstack_downloads" +# ---- Banner ------------------------------------------------- +$BANNER_ART = @' +┌────────────────────────────────────┐ +│ _ ____ _ _ ____ │ +│ __ _ ___| |_| _ \| | | | _ \ │ +│ / _` |/ _ \ __| |_) | |_| | |_) | │ +│ | (_| | __/ |_| __/| _ | __/ │ +│ \__, |\___|\__|_| |_| |_|_| │ +│ |___/ www.getPHP.org │ +└────────────────────────────────────┘ +'@ + # Pinned fallback URLs — used when live scraping/API resolution fails. # These point to the last known-good versions for each component. # Update these when bumping the pinned versions. $FALLBACK_URLS = @{ - Apache = "" - phpMyAdmin = "" + Redist = "https://aka.ms/vc14/vc_redist.x64.exe" + Apache = "https://www.apachelounge.com/download/VS18/binaries/httpd-2.4.68-260610-Win64-VS18.zip" + PHP = "https://windows.php.net/downloads/releases/php-8.5.7-Win32-vs17-x64.zip" + MariaDB = "https://archive.mariadb.org/mariadb-12.3.2/winx64-packages/mariadb-12.3.2-winx64.zip" + phpMyAdmin = "https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.zip" } # ---- Colours ----------------------------------------------- @@ -534,8 +549,10 @@ function Get-LatestMariadbUrl { foreach ($file in $release.files) { $name = $file.file_name.ToLower() if ($name -like "*winx64*" -and $name -like "*.zip" -and $name -notlike "*debugsymbols*") { - Write-Ok "MariaDB -> $($file.file_download_url)" - return $file.file_download_url + # Construct direct archive URL — bypass REST API redirector + $archiveUrl = "https://archive.mariadb.org/mariadb-$version/winx64-packages/$($file.file_name)" + Write-Ok "MariaDB -> $archiveUrl" + return $archiveUrl } } } @@ -549,7 +566,12 @@ function Get-LatestMariadbUrl { Start-Sleep -Seconds $retryDelay } else { - Write-Err "Failed to resolve MariaDB URL after $maxRetries attempts." + Write-Warn "Live resolution failed after $maxRetries attempts." + if ($FALLBACK_URLS.MariaDB) { + Write-Info " Falling back to pinned MariaDB URL: $($FALLBACK_URLS.MariaDB)" + return $FALLBACK_URLS.MariaDB + } + Write-Err "Failed to resolve MariaDB URL and no fallback URL is configured." Write-Info " Check https://mariadb.org/download/ or try again later." throw } @@ -642,48 +664,12 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { Write-Info " Retry $attempt of $maxRetries..." } - # MariaDB download URLs redirect through a CDN chain that - # Invoke-WebRequest with auto-redirect sometimes mishandles. - # Follow redirects manually, then download the final URL. - if ($url -like "*mariadb*") { - $current_url = $url - $max_redirects = 10 - $i = 0 - - while ($i -lt $max_redirects) { - $response = Invoke-WebRequest -Uri $current_url -Method Get ` - -MaximumRedirection 0 -Headers @{ "User-Agent" = $ua } ` - -ErrorAction Stop - $status = [int]$response.StatusCode - - if ($status -ge 300 -and $status -lt 400) { - $location = $response.Headers["Location"] - if (-not $location) { - throw "Redirect without Location header" - } - $current_url = $location - $i++ - continue - } - - # Final URL — download with OutFile - Invoke-WebRequest -Uri $current_url -OutFile $zipPath ` - -Headers @{ "User-Agent" = $ua } - break - } - - if ($i -ge $max_redirects) { - throw "Too many redirects resolving MariaDB download" - } + # Try with progress bar first (no -UseBasicParsing), fall back if IE not available + try { + Invoke-WebRequest -Uri $url -OutFile $zipPath -Headers @{ "User-Agent" = $ua } } - else { - # Try with progress bar first (no -UseBasicParsing), fall back if IE not available - try { - Invoke-WebRequest -Uri $url -OutFile $zipPath -Headers @{ "User-Agent" = $ua } - } - catch [System.Management.Automation.MethodInvocationException] { - Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing -Headers @{ "User-Agent" = $ua } - } + catch [System.Management.Automation.MethodInvocationException] { + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing -Headers @{ "User-Agent" = $ua } } # Download succeeded — break out of retry loop @@ -891,10 +877,11 @@ Alias /phpmyadmin "$pmaUnix" } Write-Ok "phpMyAdmin alias configured" - # 10. Error/access logs in www folder - $conf = $conf -replace 'ErrorLog\s+".*"', "ErrorLog `"$wwwUnix/error.log`"" - $conf = $conf -replace 'CustomLog\s+".*"\s+common', "CustomLog `"$wwwUnix/access.log`" common" - Write-Ok "Log files directed to $WWW_PATH" + # 10. Error/access logs in logs folder + $logsUnix = $LOGS_PATH -replace '\\', '/' + $conf = $conf -replace 'ErrorLog\s+".*"', "ErrorLog `"$logsUnix/apache_error.log`"" + $conf = $conf -replace 'CustomLog\s+".*"\s+common', "CustomLog `"$logsUnix/apache_access.log`" common" + Write-Ok "Log files directed to $LOGS_PATH" Set-Content -Path $confPath -Value $conf Write-Ok "Apache configuration complete" @@ -949,7 +936,7 @@ function Invoke-ConfigurePhp { $ini = $ini -replace 'error_reporting\s*=\s*E_ALL & ~E_DEPRECATED & ~E_STRICT', 'error_reporting = E_ALL' # Enable PHP error logging to file - $errorLogPath = "$WWW_PATH\php_errors.log" + $errorLogPath = "$LOGS_PATH\php_errors.log" $errorLogPathUnix = $errorLogPath -replace '\\', '/' if ($ini -match ';?error_log\s*=') { $ini = $ini -replace ';?error_log\s*=\s*.*', "error_log = `"$errorLogPathUnix`"" @@ -1067,6 +1054,22 @@ function Invoke-ConfigureMariaDb { Write-Warn "Configuring MariaDB..." $dataDir = "$MARIADB_PATH\data" + $logsUnix = $LOGS_PATH -replace '\\', '/' + + # Write my.ini with log-error (always, even if data dir exists) + $myIniPath = "$MARIADB_PATH\my.ini" + $myIni = @" +[mysqld] +datadir=$dataDir +log-error=$logsUnix/mariadb_error.log + +[client] +plugin-dir=$MARIADB_PATH\lib\plugin +"@ + if (-not (Test-Path $myIniPath) -or (Get-Content $myIniPath -Raw) -notmatch 'log-error') { + Set-Content -Path $myIniPath -Value $myIni + Write-Ok "MariaDB my.ini written (log-error -> $LOGS_PATH\mariadb_error.log)" + } # Check if already initialised if (Test-Path $dataDir) { @@ -1145,7 +1148,7 @@ function Invoke-ConfigurePmaStorage { # Creates the phpmyadmin config storage database and imports the schema. # Enables bookmarks, query history, table tracking, designer, etc. Write-Host "" - Write-Warn "Configuring phpMyAdmin storage..." + Write-Warn "Configuring phpMyAdmin storage (this may take a moment)..." # Check if already configured $testResult = & "$MARIADB_PATH\bin\mariadb.exe" -u root --skip-password -e "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA='phpmyadmin' AND TABLE_NAME='pma__bookmark'" 2>&1 @@ -1273,7 +1276,7 @@ function Start-WebStackServices { Write-Ok "Apache started" } else { - Write-Err "Apache failed to start - check error.log in $WWW_PATH" + Write-Err "Apache failed to start - check apache_error.log in $LOGS_PATH" Write-Info "Common causes: port 80 in use, missing VC++ Redistributable, or config error." } } @@ -1295,9 +1298,10 @@ function Start-WebStackServices { else { $dataDir = "$MARIADB_PATH\data" $mysqld = if (Test-Path "$MARIADB_PATH\bin\mariadbd.exe") { "$MARIADB_PATH\bin\mariadbd.exe" } else { "$MARIADB_PATH\bin\mysqld.exe" } + $logsUnix = $LOGS_PATH -replace '\\', '/' Start-Process -FilePath $mysqld ` - -ArgumentList "--datadir=`"$dataDir`"", "--console" ` + -ArgumentList "--datadir=`"$dataDir`" --log-error=`"$logsUnix/mariadb_error.log`"" ` -WindowStyle Hidden ` -PassThru | Out-Null @@ -1404,7 +1408,7 @@ function Install-AsServices { else { $mysqld = if (Test-Path "$MARIADB_PATH\bin\mariadbd.exe") { "$MARIADB_PATH\bin\mariadbd.exe" } else { "$MARIADB_PATH\bin\mysqld.exe" } $dataDir = "$MARIADB_PATH\data" - & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" 2>&1 | Out-Null + & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" --log-error="$LOGS_PATH\mariadb_error.log" 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Set-Service -Name $SERVICE_MARIADB -StartupType Automatic -ErrorAction SilentlyContinue Write-Ok "$SERVICE_MARIADB service installed" @@ -1440,7 +1444,7 @@ function Request-ServiceRegistration { if (-not (Get-Service -Name $SERVICE_MARIADB -ErrorAction SilentlyContinue)) { $mysqld = if (Test-Path "$MARIADB_PATH\bin\mariadbd.exe") { "$MARIADB_PATH\bin\mariadbd.exe" } else { "$MARIADB_PATH\bin\mysqld.exe" } $dataDir = "$MARIADB_PATH\data" - & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" 2>&1 | Out-Null + & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" --log-error="$LOGS_PATH\mariadb_error.log" 2>&1 | Out-Null Set-Service -Name $SERVICE_MARIADB -StartupType Automatic -ErrorAction SilentlyContinue Write-Ok "$SERVICE_MARIADB service installed" } @@ -1536,6 +1540,7 @@ function Invoke-InstallWebStack { # Create base directories New-Item -ItemType Directory -Force -Path $BASE | Out-Null New-Item -ItemType Directory -Force -Path $WWW_PATH | Out-Null + New-Item -ItemType Directory -Force -Path $LOGS_PATH | Out-Null New-Item -ItemType Directory -Force -Path $TEMP_DOWNLOADS | Out-Null # Resolve URLs (or use local zips in offline mode) @@ -1595,10 +1600,36 @@ function Invoke-InstallWebStack { return } - # Download and extract (Apache, PHP, MariaDB only — PMA deferred) - Invoke-DownloadAndExtract $apacheUrl $APACHE_PATH "Apache" - Invoke-DownloadAndExtract $phpUrl $PHP_PATH "PHP" - Invoke-DownloadAndExtract $mariadbUrl $MARIADB_PATH "MariaDB" + # Download and extract (Apache, PHP, MariaDB only — PMA deferred). + # Skip components already at the latest version. + $resolvedApacheVer = Get-VersionFromUrl $apacheUrl 'apache' + $resolvedPhpVer = Get-VersionFromUrl $phpUrl 'php' + $resolvedMariadbVer = Get-VersionFromUrl $mariadbUrl 'mariadb' + + $installedApacheVer = Get-ApacheVersion + $installedPhpVer = Get-PhpVersion + $installedMariadbVer = Get-MariaDbVersion + + if ($installedApacheVer -and $resolvedApacheVer -and ([version]$resolvedApacheVer -le [version]$installedApacheVer)) { + Write-Ok "Apache $installedApacheVer already installed — skipping download" + } else { + if ($installedApacheVer) { Write-Info "Apache $installedApacheVer -> $resolvedApacheVer" } + Invoke-DownloadAndExtract $apacheUrl $APACHE_PATH "Apache" + } + + if ($installedPhpVer -and $resolvedPhpVer -and ([version]$resolvedPhpVer -le [version]$installedPhpVer)) { + Write-Ok "PHP $installedPhpVer already installed — skipping download" + } else { + if ($installedPhpVer) { Write-Info "PHP $installedPhpVer -> $resolvedPhpVer" } + Invoke-DownloadAndExtract $phpUrl $PHP_PATH "PHP" + } + + if ($installedMariadbVer -and $resolvedMariadbVer -and ([version]$resolvedMariadbVer -le [version]$installedMariadbVer)) { + Write-Ok "MariaDB $installedMariadbVer already installed — skipping download" + } else { + if ($installedMariadbVer) { Write-Info "MariaDB $installedMariadbVer -> $resolvedMariadbVer" } + Invoke-DownloadAndExtract $mariadbUrl $MARIADB_PATH "MariaDB" + } } # Copy PHP dependency DLLs to Apache bin (ICU, curl deps, etc.) @@ -1654,18 +1685,6 @@ function Invoke-InstallWebStack { $pathEntries = Add-ToPath Write-Host "" - Write-Bold "========================================" - Write-Bold " Installation Complete!" - Write-Bold "========================================" - Write-Host "" - Write-Info " Website root: $WWW_PATH" - Write-Info " PHP test: http://localhost/phpinfo.php" - Write-Info " phpMyAdmin: http://localhost/phpmyadmin" - Write-Info " MariaDB login: root / [blank password]" - Write-Host "" - Write-Info " PHP + MariaDB added to user PATH (new terminals only)" - Write-Host "" - # Ask about Windows services BEFORE starting (avoids start-stop-restart cycle) if (-not (Test-ServicesInstalled)) { $svcChoice = Read-Host "Install as Windows services (auto-start on boot)? [y/N]" @@ -1678,7 +1697,7 @@ function Invoke-InstallWebStack { $mysqld = if (Test-Path "$MARIADB_PATH\bin\mariadbd.exe") { "$MARIADB_PATH\bin\mariadbd.exe" } else { "$MARIADB_PATH\bin\mysqld.exe" } $dataDir = "$MARIADB_PATH\data" - & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" 2>&1 | Out-Null + & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" --log-error="$LOGS_PATH\mariadb_error.log" 2>&1 | Out-Null Set-Service -Name $SERVICE_MARIADB -StartupType Automatic -ErrorAction SilentlyContinue Write-Ok "$SERVICE_MARIADB service installed" } @@ -1693,6 +1712,19 @@ function Invoke-InstallWebStack { # phpMyAdmin configuration storage (bookmarks, history, designer, etc.) Invoke-ConfigurePmaStorage + Write-Host "" + Write-Bold "========================================" + Write-Bold " Installation Complete!" + Write-Bold "========================================" + Write-Host "" + Write-Info " Website root: $WWW_PATH" + Write-Info " PHP test: http://localhost/phpinfo.php" + Write-Info " phpMyAdmin: http://localhost/phpmyadmin" + Write-Info " MariaDB login: root / [blank password]" + Write-Host "" + Write-Info " PHP + MariaDB added to user PATH (new terminals only)" + Write-Host "" + # Save config with final state (including service registration decision) Save-Config -InstallPath $BASE -Versions $versions -PathEntries $pathEntries -ServicesRegistered:(Test-ServicesInstalled) } @@ -1899,6 +1931,15 @@ function Invoke-DeleteWebStack { # Clear saved config so next run prompts for a fresh location Clear-Config Write-Info "Installer config cleared — next run will prompt for a new path." + + Write-Host "" + Write-Host "========================================" -ForegroundColor Green + Write-Host " Stack Deleted — Cleanup Complete" -ForegroundColor Yellow + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + Write-Info " Website files preserved: $WWW_PATH" + Write-Info " Database backup: $backupDir" + Write-Host "" } # ============================================================ @@ -1909,18 +1950,7 @@ function Show-Dashboard { Clear-Host Write-Host "" - - $banner = @' -┌────────────────────────────────────┐ -│ _ ____ _ _ ____ │ -│ __ _ ___| |_| _ \| | | | _ \ │ -│ / _` |/ _ \ __| |_) | |_| | |_) | │ -│ | (_| | __/ |_| __/| _ | __/ │ -│ \__, |\___|\__|_| |_| |_|_| │ -│ |___/ www.getPHP.org │ -└────────────────────────────────────┘ -'@ - Write-Host $banner -ForegroundColor Cyan + Write-Host $BANNER_ART -ForegroundColor Cyan Write-Host "" # ---- Stack Status ---- @@ -2069,9 +2099,9 @@ function Show-Dashboard { # MAIN LOOP # ============================================================ -param( - [switch]$Offline # Skip URL resolution — use pre-downloaded zips from TEMP -) +# param( +# [switch]$Offline # Skip URL resolution — use pre-downloaded zips from TEMP +# ) # Ensure we're running as Admin $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") @@ -2106,6 +2136,10 @@ if ($cpu_arch -ne 'AMD64') { exit 1 } +# ---- Banner ---- +Write-Host $BANNER_ART -ForegroundColor Cyan +Write-Host "" + # ---- VC++ Redistributable: system prerequisite (BLOCKING) ---- Write-Host "" Write-Host "========================================" -ForegroundColor White @@ -2193,10 +2227,10 @@ else { Write-Info "Press Enter to accept the default, or type a custom path." Write-Host "" - $userPath = Read-Host "Install path [C:\webstack]" + $userPath = Read-Host "Install path [%USERPROFILE%\getphp]" if ([string]::IsNullOrWhiteSpace($userPath)) { - $BASE = "C:\webstack" + $BASE = "$env:USERPROFILE\getphp" } else { # Strip trailing backslash if present @@ -2205,7 +2239,7 @@ else { # Reject paths with spaces (can break mysqld --datadir) if ($BASE -match '\s') { Write-Err "Paths containing spaces are not supported (can cause issues with MariaDB)." - Write-Info "Please use a path without spaces, e.g. C:\webstack" + Write-Info "Please use a path without spaces, e.g. C:\getphp" Write-Host "" Pause exit 1 @@ -2223,6 +2257,7 @@ $APACHE_PATH = "$BASE\apache" $PHP_PATH = "$BASE\php" $MARIADB_PATH = "$BASE\mariadb" $WWW_PATH = "$BASE\www" +$LOGS_PATH = "$BASE\logs" $PHPMYADMIN_PATH = "$WWW_PATH\phpmyadmin" if (-not $config) { From f07f66874c04f0cd913cd2a4e19626c58f39276e Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 21:38:43 +1000 Subject: [PATCH 03/25] feat: cache downloaded zips for reuse on re-install - Keep zips after extraction instead of deleting them - Check for matching cached zip before downloading (version-specific filenames) - Benefits offline mode and speeds up re-installs --- getphp.ps1 | 78 +++++++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 2b93af9..f652f5e 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -653,51 +653,58 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { $filename = [IO.Path]::GetFileName($url) $zipPath = Join-Path $TEMP_DOWNLOADS $filename - $ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" - $maxRetries = 3 - $retryDelay = 5 + # Check if we already have this exact version cached + if (Test-Path $zipPath) { + Write-Ok "$label zip already cached — using $filename" + Write-Info "Extracting to $dest..." + Expand-Archive -Path $zipPath -DestinationPath $dest -Force + } else { + $ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" - for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { - try { - if ($attempt -gt 1) { - Write-Info " Retry $attempt of $maxRetries..." - } + $maxRetries = 3 + $retryDelay = 5 - # Try with progress bar first (no -UseBasicParsing), fall back if IE not available + for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { try { - Invoke-WebRequest -Uri $url -OutFile $zipPath -Headers @{ "User-Agent" = $ua } - } - catch [System.Management.Automation.MethodInvocationException] { - Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing -Headers @{ "User-Agent" = $ua } - } + if ($attempt -gt 1) { + Write-Info " Retry $attempt of $maxRetries..." + } - # Download succeeded — break out of retry loop - break - } - catch { - Write-Progress -Activity "Downloading $label" -Completed - if ($attempt -lt $maxRetries) { - Write-Warn " Download attempt $attempt failed: $($_.Exception.Message)" - Write-Info " Retrying in $retryDelay seconds..." - # Force cleanup of any lingering file handles before delete - [System.GC]::Collect() - [System.GC]::WaitForPendingFinalizers() - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds $retryDelay + # Try with progress bar first, fall back if IE not available + try { + Invoke-WebRequest -Uri $url -OutFile $zipPath -Headers @{ "User-Agent" = $ua } + } + catch [System.Management.Automation.MethodInvocationException] { + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing -Headers @{ "User-Agent" = $ua } + } + + # Download succeeded — break out of retry loop + break } - else { - throw "Download failed for $label after $maxRetries attempts: $($_.Exception.Message)" + catch { + Write-Progress -Activity "Downloading $label" -Completed + if ($attempt -lt $maxRetries) { + Write-Warn " Download attempt $attempt failed: $($_.Exception.Message)" + Write-Info " Retrying in $retryDelay seconds..." + [System.GC]::Collect() + [System.GC]::WaitForPendingFinalizers() + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds $retryDelay + } + else { + throw "Download failed for $label after $maxRetries attempts: $($_.Exception.Message)" + } } } - } - if (-not (Test-Path $zipPath)) { - throw "Download failed - file not found: $zipPath" - } + if (-not (Test-Path $zipPath)) { + throw "Download failed - file not found: $zipPath" + } - Write-Info "Extracting to $dest..." - Expand-Archive -Path $zipPath -DestinationPath $dest -Force + Write-Info "Extracting to $dest..." + Expand-Archive -Path $zipPath -DestinationPath $dest -Force + } # Flatten wrapper folder if present. # Apache Lounge = Apache24/ | PHP = php-8.x.x-Win32-vs17-x64/ @@ -732,7 +739,6 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { } } - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue Write-Ok "$label extracted" } From 9f750979b34f44897428d41dd2df455fa555a462 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 22:04:43 +1000 Subject: [PATCH 04/25] =?UTF-8?q?refactor:=20de-duplicate=20service=20regi?= =?UTF-8?q?stration=20=E2=80=94=20route=20through=20Install-AsServices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- getphp.ps1 | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index f652f5e..48a7378 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -1439,21 +1439,7 @@ function Request-ServiceRegistration { $choice = Read-Host "Register as Windows services now? [y/N]" if ($choice -match "^[Yy]") { - Write-Info "Registering Windows services..." - - if (-not (Get-Service -Name $SERVICE_APACHE -ErrorAction SilentlyContinue)) { - & "$APACHE_PATH\bin\httpd.exe" -k install -n $SERVICE_APACHE 2>&1 | Out-Null - Set-Service -Name $SERVICE_APACHE -StartupType Automatic -ErrorAction SilentlyContinue - Write-Ok "$SERVICE_APACHE service installed" - } - - if (-not (Get-Service -Name $SERVICE_MARIADB -ErrorAction SilentlyContinue)) { - $mysqld = if (Test-Path "$MARIADB_PATH\bin\mariadbd.exe") { "$MARIADB_PATH\bin\mariadbd.exe" } else { "$MARIADB_PATH\bin\mysqld.exe" } - $dataDir = "$MARIADB_PATH\data" - & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" --log-error="$LOGS_PATH\mariadb_error.log" 2>&1 | Out-Null - Set-Service -Name $SERVICE_MARIADB -StartupType Automatic -ErrorAction SilentlyContinue - Write-Ok "$SERVICE_MARIADB service installed" - } + Install-AsServices # Update config to reflect registered services Save-Config -InstallPath $BASE -ServicesRegistered:$true @@ -1695,17 +1681,7 @@ function Invoke-InstallWebStack { if (-not (Test-ServicesInstalled)) { $svcChoice = Read-Host "Install as Windows services (auto-start on boot)? [y/N]" if ($svcChoice -match "^[Yy]") { - # Register services now, then Start-WebStackServices will use service control - Write-Info "Registering Windows services..." - & "$APACHE_PATH\bin\httpd.exe" -k install -n $SERVICE_APACHE 2>&1 | Out-Null - Set-Service -Name $SERVICE_APACHE -StartupType Automatic -ErrorAction SilentlyContinue - Write-Ok "$SERVICE_APACHE service installed" - - $mysqld = if (Test-Path "$MARIADB_PATH\bin\mariadbd.exe") { "$MARIADB_PATH\bin\mariadbd.exe" } else { "$MARIADB_PATH\bin\mysqld.exe" } - $dataDir = "$MARIADB_PATH\data" - & $mysqld --install $SERVICE_MARIADB --datadir="$dataDir" --log-error="$LOGS_PATH\mariadb_error.log" 2>&1 | Out-Null - Set-Service -Name $SERVICE_MARIADB -StartupType Automatic -ErrorAction SilentlyContinue - Write-Ok "$SERVICE_MARIADB service installed" + Install-AsServices } else { Write-Info "Services will run as processes (started via this script)." From c7823a27268fa40ce1224650143d4f116f380a68 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 22:16:45 +1000 Subject: [PATCH 05/25] fix: extract phpMyAdmin zip in offline mode, skip URL-based download --- getphp.ps1 | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 48a7378..ff13fa4 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -1567,9 +1567,10 @@ function Invoke-InstallWebStack { } # Extract directly - if ($apacheZip) { Invoke-ExtractZip $apacheZip $APACHE_PATH "Apache" } - if ($phpZip) { Invoke-ExtractZip $phpZip $PHP_PATH "PHP" } - if ($mariadbZip) { Invoke-ExtractZip $mariadbZip $MARIADB_PATH "MariaDB" } + if ($apacheZip) { Invoke-ExtractZip $apacheZip $APACHE_PATH "Apache" } + if ($phpZip) { Invoke-ExtractZip $phpZip $PHP_PATH "PHP" } + if ($mariadbZip) { Invoke-ExtractZip $mariadbZip $MARIADB_PATH "MariaDB" } + if ($pmaZip) { Invoke-ExtractZip $pmaZip $PHPMYADMIN_PATH "phpMyAdmin" } if (-not $apacheZip -or -not $phpZip -or -not $mariadbZip) { Write-Err "Could not identify all required zips by filename convention." @@ -1655,10 +1656,12 @@ function Invoke-InstallWebStack { Invoke-ConfigureMariaDb - # ── phpMyAdmin ────────────────────────────────────────── - Write-Host "" - Write-Bold "── phpMyAdmin ──" - Invoke-DownloadAndExtract $pmaUrl $PHPMYADMIN_PATH "phpMyAdmin" + if (-not $Offline) { + # ── phpMyAdmin ────────────────────────────────────────── + Write-Host "" + Write-Bold "── phpMyAdmin ──" + Invoke-DownloadAndExtract $pmaUrl $PHPMYADMIN_PATH "phpMyAdmin" + } Invoke-ConfigurePhpMyAdmin # Create test file From 54fe92327d31a9f7047201278dab0be34b53a7f5 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 22:21:19 +1000 Subject: [PATCH 06/25] fix: remove logs directory on delete --- getphp.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/getphp.ps1 b/getphp.ps1 index ff13fa4..85470a0 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -1902,6 +1902,9 @@ function Invoke-DeleteWebStack { Remove-Item $PHPMYADMIN_PATH -Recurse -Force -ErrorAction SilentlyContinue Write-Ok "phpMyAdmin removed" + Remove-Item $LOGS_PATH -Recurse -Force -ErrorAction SilentlyContinue + Write-Ok "Log files removed" + Write-Host "" Write-Ok "PHP web stack deleted." Write-Info "Your website files in $WWW_PATH were preserved." From 4f1b884d6609b58927f7f4b1dacf0952b2a4ab3b Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 22:23:51 +1000 Subject: [PATCH 07/25] fix: cache SQLite3 DLL zip in temp downloads, skip re-download --- getphp.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 85470a0..0f4e31f 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -999,7 +999,11 @@ function Invoke-FixSqliteDll { $zipFile = "$TEMP_DOWNLOADS\sqlite3_dll.zip" Write-Info "Downloading latest SQLite3 DLL..." - Invoke-WebRequest $url -OutFile $zipFile -UseBasicParsing -Headers @{ "User-Agent" = $ua } + if (-not (Test-Path $zipFile)) { + Invoke-WebRequest $url -OutFile $zipFile -UseBasicParsing -Headers @{ "User-Agent" = $ua } + } else { + Write-Ok "SQLite3 DLL zip already cached — using $zipFile" + } $extractDir = "$TEMP_DOWNLOADS\sqlite3_dll_extract" New-Item -ItemType Directory -Force -Path $extractDir | Out-Null @@ -1016,7 +1020,6 @@ function Invoke-FixSqliteDll { Write-Warn "Could not find sqlite3.dll in downloaded archive - skipping" } - Remove-Item $zipFile -Force -ErrorAction SilentlyContinue Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue } else { From 0724e4ae2958faf759691b1eb5b0a963e1ae79fb Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 22:32:36 +1000 Subject: [PATCH 08/25] fix: PHP fallback URL, cache VC++ installer, standardise on vc14 redist URL --- getphp.ps1 | 66 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 0f4e31f..dd1cb71 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -328,46 +328,51 @@ function Install-VcRedist { } # Fallback: direct download (for systems without winget) - $installer = "$env:TEMP\vc_redist.x64.exe" + $installer = "$TEMP_DOWNLOADS\vc_redist.x64.exe" + New-Item -ItemType Directory -Force -Path $TEMP_DOWNLOADS | Out-Null - $maxRetries = 3 - $retryDelay = 5 - $downloaded = $false + if (Test-Path $installer) { + Write-Ok "VC++ Redistributable installer already cached — using $installer" + } + else { + $maxRetries = 3 + $retryDelay = 5 + $downloaded = $false - for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { - try { - if ($attempt -gt 1) { - Write-Info " Retry $attempt of $maxRetries..." - } - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile $installer - $downloaded = $true - break - } - catch { - if ($attempt -lt $maxRetries) { - Write-Warn "Download attempt $attempt failed. Retrying in $retryDelay seconds..." - Start-Sleep -Seconds $retryDelay + for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { + try { + if ($attempt -gt 1) { + Write-Info " Retry $attempt of $maxRetries..." + } + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri "https://aka.ms/vc14/vc_redist.x64.exe" -OutFile $installer + $downloaded = $true + break } - else { - Write-Err "Failed to download VC++ Redistributable after $maxRetries attempts: $_" - Write-Info "Install manually: https://aka.ms/vs/17/release/vc_redist.x64.exe" - return + catch { + if ($attempt -lt $maxRetries) { + Write-Warn "Download attempt $attempt failed. Retrying in $retryDelay seconds..." + Start-Sleep -Seconds $retryDelay + } + else { + Write-Err "Failed to download VC++ Redistributable after $maxRetries attempts: $_" + Write-Info "Install manually: https://aka.ms/vc14/vc_redist.x64.exe" + return + } } } - } - if (-not $downloaded) { return } + if (-not $downloaded) { return } + } Write-Info "Running installer (silent -- this may take a moment)..." $proc = Start-Process -FilePath $installer -ArgumentList "/install", "/quiet", "/norestart" -Wait -PassThru - Remove-Item $installer -Force -ErrorAction SilentlyContinue if ($proc.ExitCode -eq 0 -or $proc.ExitCode -eq 3010) { Write-Ok "Visual C++ Redistributable installed successfully" } else { Write-Warn "Installer exited with code $($proc.ExitCode). Install manually:" - Write-Info " https://aka.ms/vs/17/release/vc_redist.x64.exe" + Write-Info " https://aka.ms/vc14/vc_redist.x64.exe" } } @@ -498,7 +503,12 @@ function Get-LatestPhpUrl { Start-Sleep -Seconds $retryDelay } else { - Write-Err "Failed to resolve PHP URL after $maxRetries attempts." + Write-Warn "Live resolution failed after $maxRetries attempts." + if ($FALLBACK_URLS.PHP) { + Write-Info " Falling back to pinned PHP URL: $($FALLBACK_URLS.PHP)" + return $FALLBACK_URLS.PHP + } + Write-Err "Failed to resolve PHP URL and no fallback URL is configured." Write-Info " Check https://windows.php.net/ or try again later." throw } @@ -1271,7 +1281,7 @@ function Start-WebStackServices { Write-Host $testResult -ForegroundColor DarkGray Write-Info "If the error mentions missing DLLs (VCRUNTIME, MSVCP, etc.)," Write-Info "install the Visual C++ Redistributable from:" - Write-Info " https://aka.ms/vs/17/release/vc_redist.x64.exe" + Write-Info " https://aka.ms/vc14/vc_redist.x64.exe" return } From 4954866e9a595e8a1ac187590faf37be109bded1 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 23:14:02 +1000 Subject: [PATCH 09/25] perf: skip extraction when destination already populated (cached zip + existing files) --- getphp.ps1 | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index dd1cb71..0afd875 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -667,6 +667,14 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { # Check if we already have this exact version cached if (Test-Path $zipPath) { Write-Ok "$label zip already cached — using $filename" + + # Skip extraction if destination already populated (re-install of same version) + $existing = @(Get-ChildItem $dest -Force -ErrorAction SilentlyContinue) + if ($existing.Count -gt 0) { + Write-Ok "$label already extracted — skipping" + return + } + Write-Info "Extracting to $dest..." Expand-Archive -Path $zipPath -DestinationPath $dest -Force } else { @@ -712,6 +720,13 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { throw "Download failed - file not found: $zipPath" } + # Skip extraction if destination already populated (offline mode pre-extracted) + $existing = @(Get-ChildItem $dest -Force -ErrorAction SilentlyContinue) + if ($existing.Count -gt 0) { + Write-Ok "$label already extracted — skipping" + return + } + Write-Info "Extracting to $dest..." Expand-Archive -Path $zipPath -DestinationPath $dest -Force } @@ -1129,7 +1144,7 @@ plugin-dir=$MARIADB_PATH\lib\plugin function Invoke-ConfigurePhpMyAdmin { Write-Host "" - Write-Warn "Configuring phpMyAdmin, Test Script & System Paths..." + Write-Warn "Configuring phpMyAdmin & Test Script..." $configPath = "$PHPMYADMIN_PATH\config.inc.php" @@ -1688,7 +1703,8 @@ function Invoke-InstallWebStack { mariadb = Get-MariaDbVersion phpmyadmin = (Get-PhpMyAdminVersion) } - + Write-Host "" + Write-Warn "Configuring paths (this may take a moment)..." # Add PHP + MariaDB to user PATH (removes old entries from previous install) $pathEntries = Add-ToPath @@ -1868,6 +1884,7 @@ function Invoke-DeleteWebStack { Write-Warn " - PHP ($PHP_PATH)" Write-Warn " - MariaDB binaries ($MARIADB_PATH\bin)" Write-Warn " - phpMyAdmin ($PHPMYADMIN_PATH)" + Write-Warn " - Log files ($LOGS_PATH)" Write-Host "" Write-Info "The following will NOT be deleted:" Write-Info " - Your website files in $WWW_PATH" @@ -1934,9 +1951,9 @@ function Invoke-DeleteWebStack { Write-Info "Installer config cleared — next run will prompt for a new path." Write-Host "" - Write-Host "========================================" -ForegroundColor Green - Write-Host " Stack Deleted — Cleanup Complete" -ForegroundColor Yellow - Write-Host "========================================" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor White + Write-Host " Stack Deleted — Cleanup Complete" -ForegroundColor White + Write-Host "========================================" -ForegroundColor White Write-Host "" Write-Info " Website files preserved: $WWW_PATH" Write-Info " Database backup: $backupDir" From 4bb451192e7ff883f444785142d1f047140c987e Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 23:44:13 +1000 Subject: [PATCH 10/25] fix: suppress progress bars only during bulk file flattening (preserve download progress) --- getphp.ps1 | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 0afd875..b2cca17 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -738,6 +738,10 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { $dirsOnly = @($allItems | Where-Object { $_ -is [System.IO.DirectoryInfo] }) $filesOnly = @($allItems | Where-Object { $_ -is [System.IO.FileInfo] }) + # Suppress progress bars during bulk file moves (phpMyAdmin has ~4,300 files) + $prevProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + # Strategy: if there's exactly one directory and no loose files, flatten it if ($dirsOnly.Count -eq 1 -and $filesOnly.Count -eq 0) { $inner = $dirsOnly[0].FullName @@ -764,6 +768,8 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { } } + $ProgressPreference = $prevProgress + Write-Ok "$label extracted" } @@ -781,10 +787,14 @@ function Invoke-ExtractZip($zipPath, $dest, $label) { if ($dirsOnly.Count -eq 1 -and $filesOnly.Count -eq 0) { $inner = $dirsOnly[0].FullName Write-Info " Flattening wrapper folder: $($dirsOnly[0].Name)" + + $prevProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' Get-ChildItem $inner -Force | ForEach-Object { Move-Item $_.FullName $dest -Force -ErrorAction SilentlyContinue } Remove-Item $inner -Recurse -Force -ErrorAction SilentlyContinue + $ProgressPreference = $prevProgress } Write-Ok "$label extracted" } @@ -1911,13 +1921,14 @@ function Invoke-DeleteWebStack { Write-Warn "Existing data_backup found — renaming to data_backup_$ts" Rename-Item $backupDir $oldBackup } - + Write-Host "" Write-Info "Backing up database data to $backupDir ..." New-Item -ItemType Directory -Force -Path $backupDir | Out-Null Get-ChildItem $dataDir | ForEach-Object { Move-Item $_.FullName $backupDir -Force } Write-Ok "Database data preserved at $backupDir" + Write-Host "" } Remove-Item $APACHE_PATH -Recurse -Force -ErrorAction SilentlyContinue @@ -1934,11 +1945,7 @@ function Invoke-DeleteWebStack { Remove-Item $LOGS_PATH -Recurse -Force -ErrorAction SilentlyContinue Write-Ok "Log files removed" - Write-Host "" - Write-Ok "PHP web stack deleted." - Write-Info "Your website files in $WWW_PATH were preserved." - Write-Info "Your database data was backed up to $backupDir" # Unregister Windows services if present Remove-Services @@ -1955,8 +1962,8 @@ function Invoke-DeleteWebStack { Write-Host " Stack Deleted — Cleanup Complete" -ForegroundColor White Write-Host "========================================" -ForegroundColor White Write-Host "" - Write-Info " Website files preserved: $WWW_PATH" - Write-Info " Database backup: $backupDir" + Write-Info "Website files preserved: $WWW_PATH" + Write-Info "Database backup: $backupDir" Write-Host "" } From 603feed474b35238a7aa747086d2dae83f28c69f Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 13 Jun 2026 23:55:06 +1000 Subject: [PATCH 11/25] =?UTF-8?q?feat:=20enable=20-Offline=20switch=20?= =?UTF-8?q?=E2=80=94=20param()=20must=20be=20first=20executable=20statemen?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- getphp.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index b2cca17..fe61a13 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -9,6 +9,10 @@ # ============================================================ #Requires -RunAsAdministrator +param( + [switch]$Offline # Skip URL resolution — use pre-downloaded zips from TEMP +) + # ---- Config ------------------------------------------------- $TEMP_DOWNLOADS = "$env:TEMP\webstack_downloads" @@ -2124,10 +2128,6 @@ function Show-Dashboard { # MAIN LOOP # ============================================================ -# param( -# [switch]$Offline # Skip URL resolution — use pre-downloaded zips from TEMP -# ) - # Ensure we're running as Admin $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") From caad76370a94079b97a2000c648e21f4c83f5135 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sun, 14 Jun 2026 00:11:26 +1000 Subject: [PATCH 12/25] =?UTF-8?q?fix:=20Invoke-ExtractZip=20flattening=20?= =?UTF-8?q?=E2=80=94=20handle=20multi-file=20zips=20with=20known=20wrapper?= =?UTF-8?q?=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- getphp.ps1 | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/getphp.ps1 b/getphp.ps1 index fe61a13..85acb87 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -800,6 +800,26 @@ function Invoke-ExtractZip($zipPath, $dest, $label) { Remove-Item $inner -Recurse -Force -ErrorAction SilentlyContinue $ProgressPreference = $prevProgress } + elseif ($dirsOnly.Count -ge 1) { + # Multiple directories or mixed files/dirs — try known wrapper patterns + $knownWrappers = @('Apache24', 'php-*', 'mariadb-*', 'phpMyAdmin-*') + foreach ($pattern in $knownWrappers) { + $match = @($dirsOnly | Where-Object { $_.Name -like $pattern }) + if ($match.Count -eq 1) { + $inner = $match[0].FullName + Write-Info " Flattening wrapper folder: $($match[0].Name)" + + $prevProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Get-ChildItem $inner -Force | ForEach-Object { + Move-Item $_.FullName $dest -Force -ErrorAction SilentlyContinue + } + Remove-Item $inner -Recurse -Force -ErrorAction SilentlyContinue + $ProgressPreference = $prevProgress + break + } + } + } Write-Ok "$label extracted" } From 810e37f874aaba98aa68971ddf3239a4d242cd81 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sun, 14 Jun 2026 00:30:41 +1000 Subject: [PATCH 13/25] feat: enable sodium PHP extension --- getphp.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/getphp.ps1 b/getphp.ps1 index 85acb87..2b2d2fc 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -988,6 +988,7 @@ function Invoke-ConfigurePhp { 'extension=openssl', 'extension=pdo_mysql', 'extension=pdo_sqlite', + 'extension=sodium', 'extension=sqlite3' ) @@ -1033,7 +1034,7 @@ function Invoke-ConfigurePhp { Write-Ok "OPCache enabled (256 MB, JIT tracing, production-ready)" Set-Content -Path $iniPath -Value $ini - Write-Ok "PHP extensions enabled: curl, fileinfo, gd, intl, mbstring, mysqli, openssl, pdo_mysql, pdo_sqlite, sqlite3" + Write-Ok "PHP extensions enabled: curl, fileinfo, gd, intl, mbstring, mysqli, openssl, pdo_mysql, pdo_sqlite, sodium, sqlite3" } function Invoke-FixSqliteDll { From 430249b215647b0b74191ff7f8064d902e554ef1 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sun, 14 Jun 2026 00:33:20 +1000 Subject: [PATCH 14/25] docs: sodium extension, offline mode, download caching, log cleanup on delete --- README_Win.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README_Win.md b/README_Win.md index 3aa4470..cf3e4f2 100644 --- a/README_Win.md +++ b/README_Win.md @@ -167,7 +167,7 @@ Example `config.json`: ### PHP -- **Extensions enabled:** `curl`, `fileinfo`, `gd`, `intl`, `mbstring`, `mysqli`, `openssl`, `pdo_mysql`, `pdo_sqlite`, `sqlite3` +- **Extensions enabled:** `curl`, `fileinfo`, `gd`, `intl`, `mbstring`, `mysqli`, `openssl`, `pdo_mysql`, `pdo_sqlite`, `sodium`, `sqlite3` - `display_errors = On` for development - **Error logging:** `error_log = logs/php_errors.log` - **OPCache:** Enabled with 256 MB memory, 16 MB interned strings, 20,000 files, JIT tracing with 100 MB buffer — production-ready out of the box @@ -206,7 +206,7 @@ The delete command (`D`) preserves your data: 1. Services are stopped 2. `mariadb\data\` is moved to `data_backup\` -3. Apache, PHP, MariaDB, and phpMyAdmin are removed +3. Apache, PHP, MariaDB, phpMyAdmin, and log files are removed 4. `www\` (your websites) is left untouched 5. If `data_backup\` already exists from a previous delete, it is timestamped (`data_backup_20260605_213000`) to avoid collisions @@ -252,6 +252,23 @@ Unlike most installers that hardcode version numbers, `getphp.ps1` dynamically r - **MariaDB** — Queries the MariaDB REST API (`/rest-api/mariadb/`), sorts stable releases by support policy (Rolling > LTS), then by version number. Constructs direct archive URL from version and filename (bypasses REST API redirector). Excludes debug-symbols-only zips. - **phpMyAdmin** — Scrapes the phpMyAdmin downloads page, finds all stable `all-languages.zip` files (excluding snapshots), picks the highest version +## Offline Mode & Download Caching + +Run the script with `-Offline` to skip all URL resolution and downloading: + +```powershell +.\getphp.ps1 -Offline +``` + +Offline mode requires four pre-downloaded zip files in `%TEMP%\webstack_downloads\` (Apache, PHP, MariaDB, phpMyAdmin) — run the script online once to populate the cache, then subsequent installs skip downloads entirely. + +All downloaded files are cached permanently in `%TEMP%\webstack_downloads\`: +- Component zips (Apache, PHP, MariaDB, phpMyAdmin) — reused on re-install when the version hasn't changed +- SQLite3 DLL zip — cached and reused +- VC++ Redistributable installer (`.exe`) — cached and reused + +Extraction is also skipped when the destination directory is already populated, making re-installs of the same version nearly instant. + ## Known Quirks & Fixes ### ARM64 / Snapdragon / Apple Silicon (Windows VM) From f6c063bd3f1d8bf8755839c34c6446fab0f4f069 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sun, 14 Jun 2026 00:57:47 +1000 Subject: [PATCH 15/25] =?UTF-8?q?fix:=20extraction=20skip=20only=20on=20ca?= =?UTF-8?q?ched=20zip=20=E2=80=94=20new=20downloads=20must=20always=20extr?= =?UTF-8?q?act?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- getphp.ps1 | 7 ------- 1 file changed, 7 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 2b2d2fc..304e70c 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -724,13 +724,6 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { throw "Download failed - file not found: $zipPath" } - # Skip extraction if destination already populated (offline mode pre-extracted) - $existing = @(Get-ChildItem $dest -Force -ErrorAction SilentlyContinue) - if ($existing.Count -gt 0) { - Write-Ok "$label already extracted — skipping" - return - } - Write-Info "Extracting to $dest..." Expand-Archive -Path $zipPath -DestinationPath $dest -Force } From ed3ae7a90b327217882662903e3ae47cfbadc3a1 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sun, 14 Jun 2026 00:58:01 +1000 Subject: [PATCH 16/25] docs: clarify extraction skip only applies to cached zips --- README_Win.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_Win.md b/README_Win.md index cf3e4f2..df583ef 100644 --- a/README_Win.md +++ b/README_Win.md @@ -267,7 +267,7 @@ All downloaded files are cached permanently in `%TEMP%\webstack_downloads\`: - SQLite3 DLL zip — cached and reused - VC++ Redistributable installer (`.exe`) — cached and reused -Extraction is also skipped when the destination directory is already populated, making re-installs of the same version nearly instant. +Extraction is also skipped when the zip was cached and the destination is already populated — same-version re-installs are nearly instant. ## Known Quirks & Fixes From e1c3225af50b69360cceaead681bb3cdcfecc8e6 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sun, 14 Jun 2026 01:06:17 +1000 Subject: [PATCH 17/25] =?UTF-8?q?refactor:=20remove=20dead=20extraction-sk?= =?UTF-8?q?ip=20logic=20=E2=80=94=20version-guard=20in=20Install=20already?= =?UTF-8?q?=20handles=20this?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README_Win.md | 2 -- getphp.ps1 | 8 -------- 2 files changed, 10 deletions(-) diff --git a/README_Win.md b/README_Win.md index df583ef..a278703 100644 --- a/README_Win.md +++ b/README_Win.md @@ -267,8 +267,6 @@ All downloaded files are cached permanently in `%TEMP%\webstack_downloads\`: - SQLite3 DLL zip — cached and reused - VC++ Redistributable installer (`.exe`) — cached and reused -Extraction is also skipped when the zip was cached and the destination is already populated — same-version re-installs are nearly instant. - ## Known Quirks & Fixes ### ARM64 / Snapdragon / Apple Silicon (Windows VM) diff --git a/getphp.ps1 b/getphp.ps1 index 304e70c..f2a9ea8 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -671,14 +671,6 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { # Check if we already have this exact version cached if (Test-Path $zipPath) { Write-Ok "$label zip already cached — using $filename" - - # Skip extraction if destination already populated (re-install of same version) - $existing = @(Get-ChildItem $dest -Force -ErrorAction SilentlyContinue) - if ($existing.Count -gt 0) { - Write-Ok "$label already extracted — skipping" - return - } - Write-Info "Extracting to $dest..." Expand-Archive -Path $zipPath -DestinationPath $dest -Force } else { From 3b6d3ea89e1034bc34e867ed1804bc94a9ee682f Mon Sep 17 00:00:00 2001 From: Simon Field Date: Tue, 16 Jun 2026 01:45:17 +1000 Subject: [PATCH 18/25] feat: forced update (fu), version switching, db backup, and extraction fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hidden 'fu' command: offline forced update with interactive version selection per component (newer/older/current tags) - Get-VersionFromZipName helper with 3-part version normalization - MariaDB database backup/restore during updates (u and fu) - DRY helpers: Backup-MariaDbData, Restore-MariaDbData, Save-PostUpdateConfig - phpMyAdmin regex: handle +snapshot filenames - Progress suppression across all extraction paths - Invoke-CopyPhpDlls: dynamic ICU DLL discovery - PATH entries preserved from existing config during updates - Start-Sleep after stopping services to release file handles - Pipe alignment and UI cleanup in version summary — assisted by Hymie (DaFa's AI) --- getphp.ps1 | 331 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 290 insertions(+), 41 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index f2a9ea8..baa94de 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -114,6 +114,24 @@ function Get-VersionFromUrl([string]$url, [string]$component) { return $null } +# Extract version string from a cached zip filename (offline mode) +function Get-VersionFromZipName([string]$filename, [string]$component) { + $ver = $null + switch ($component) { + 'apache' { if ($filename -match 'httpd-([\d.]+)-') { $ver = $matches[1] } } + 'php' { if ($filename -match 'php-([\d.]+)-') { $ver = $matches[1] } } + 'mariadb' { if ($filename -match 'mariadb-([\d.]+)-') { $ver = $matches[1] } } + 'phpmyadmin' { if ($filename -match 'phpMyAdmin-([\d.]+)') { $ver = $matches[1] } } + } + if ($ver) { + # Normalize to at least 3 version parts (e.g. 6.0 → 6.0.0) + $parts = $ver -split '\.' + while ($parts.Count -lt 3) { $parts += '0' } + return ($parts -join '.') + } + return $null +} + # ---- Config Persistence -------------------------------------- $CONFIG_FILE = "$env:APPDATA\getphp\config.json" @@ -658,6 +676,9 @@ function Get-LatestPhpMyAdminUrl { # ============================================================ function Invoke-DownloadAndExtract($url, $dest, $label) { + $prevProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Write-Host "" Write-Host "Downloading $label..." -ForegroundColor Yellow Write-Info " $url" @@ -727,10 +748,6 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { $dirsOnly = @($allItems | Where-Object { $_ -is [System.IO.DirectoryInfo] }) $filesOnly = @($allItems | Where-Object { $_ -is [System.IO.FileInfo] }) - # Suppress progress bars during bulk file moves (phpMyAdmin has ~4,300 files) - $prevProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' - # Strategy: if there's exactly one directory and no loose files, flatten it if ($dirsOnly.Count -eq 1 -and $filesOnly.Count -eq 0) { $inner = $dirsOnly[0].FullName @@ -764,6 +781,9 @@ function Invoke-DownloadAndExtract($url, $dest, $label) { # Offline-only: extract a pre-downloaded zip directly (no download step). function Invoke-ExtractZip($zipPath, $dest, $label) { + $prevProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Write-Host "" Write-Info "Extracting $label from $zipPath..." Expand-Archive -Path $zipPath -DestinationPath $dest -Force @@ -775,15 +795,11 @@ function Invoke-ExtractZip($zipPath, $dest, $label) { if ($dirsOnly.Count -eq 1 -and $filesOnly.Count -eq 0) { $inner = $dirsOnly[0].FullName - Write-Info " Flattening wrapper folder: $($dirsOnly[0].Name)" - - $prevProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' + Write-Info "Flattening wrapper folder: $($dirsOnly[0].Name)" Get-ChildItem $inner -Force | ForEach-Object { Move-Item $_.FullName $dest -Force -ErrorAction SilentlyContinue } Remove-Item $inner -Recurse -Force -ErrorAction SilentlyContinue - $ProgressPreference = $prevProgress } elseif ($dirsOnly.Count -ge 1) { # Multiple directories or mixed files/dirs — try known wrapper patterns @@ -792,19 +808,17 @@ function Invoke-ExtractZip($zipPath, $dest, $label) { $match = @($dirsOnly | Where-Object { $_.Name -like $pattern }) if ($match.Count -eq 1) { $inner = $match[0].FullName - Write-Info " Flattening wrapper folder: $($match[0].Name)" - - $prevProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' + Write-Info "Flattening wrapper folder: $($match[0].Name)" Get-ChildItem $inner -Force | ForEach-Object { Move-Item $_.FullName $dest -Force -ErrorAction SilentlyContinue } Remove-Item $inner -Recurse -Force -ErrorAction SilentlyContinue - $ProgressPreference = $prevProgress break } } } + + $ProgressPreference = $prevProgress Write-Ok "$label extracted" } @@ -1080,16 +1094,8 @@ function Invoke-CopyPhpDlls { Write-Host "" Write-Warn "Copying PHP dependency DLLs to Apache bin..." - $phpDlls = @( - 'icudt77.dll', - 'icuin77.dll', - 'icuio77.dll', - 'icuuc77.dll', - 'libssh2.dll', - 'nghttp2.dll', - 'libzstd.dll', - 'libsodium.dll' - ) + $phpDlls = @(Get-ChildItem "$PHP_PATH\icu*.dll" -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) + $phpDlls += @('libssh2.dll', 'nghttp2.dll', 'libzstd.dll', 'libsodium.dll') foreach ($dll in $phpDlls) { $src = "$PHP_PATH\$dll" @@ -1201,8 +1207,6 @@ function Invoke-ConfigurePhpMyAdmin { function Invoke-ConfigurePmaStorage { # Creates the phpmyadmin config storage database and imports the schema. # Enables bookmarks, query history, table tracking, designer, etc. - Write-Host "" - Write-Warn "Configuring phpMyAdmin storage (this may take a moment)..." # Check if already configured $testResult = & "$MARIADB_PATH\bin\mariadb.exe" -u root --skip-password -e "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA='phpmyadmin' AND TABLE_NAME='pma__bookmark'" 2>&1 @@ -1767,6 +1771,56 @@ function Invoke-InstallWebStack { # UPDATE # ============================================================ +function Backup-MariaDbData { + $dataDir = "$MARIADB_PATH\data" + $backupDir = "$BASE\data_backup_update" + if (Test-Path $dataDir) { + Write-Info "Backing up databases before MariaDB update..." + if (Test-Path $backupDir) { + Remove-Item $backupDir -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + Get-ChildItem $dataDir | ForEach-Object { + Move-Item $_.FullName $backupDir -Force + } + Write-Ok "Databases backed up" + } +} + +function Restore-MariaDbData { + $backupDir = "$BASE\data_backup_update" + if (Test-Path $backupDir) { + $newDataDir = "$MARIADB_PATH\data" + New-Item -ItemType Directory -Force -Path $newDataDir | Out-Null + Get-ChildItem $backupDir | ForEach-Object { + Move-Item $_.FullName $newDataDir -Force + } + Remove-Item $backupDir -Force + Write-Ok "Databases restored" + } +} + +function Save-PostUpdateConfig($needsApache, $needsPhp, $needsMariadb, $needsPma) { + $existingConfig = Get-Config + $versions = @{ + apache = $(if ($needsApache) { Get-ApacheVersion } else { $existingConfig.versions.apache }) + php = $(if ($needsPhp) { Get-PhpVersion } else { $existingConfig.versions.php }) + mariadb = $(if ($needsMariadb) { Get-MariaDbVersion } else { $existingConfig.versions.mariadb }) + phpmyadmin = $(if ($needsPma) { Get-PhpMyAdminVersion } else { $existingConfig.versions.phpmyadmin }) + } + $pathEntries = $existingConfig.path_entries + + if (-not (Test-ServicesInstalled)) { + Write-Host "" + $svcChoice = Read-Host "Install as Windows services (auto-start on boot)? [y/N]" + if ($svcChoice -match "^[Yy]") { + Install-AsServices + } + } + + Save-Config -InstallPath $BASE -Versions $versions -PathEntries $pathEntries -ServicesRegistered:(Test-ServicesInstalled) +} + function Invoke-UpdateWebStack { Write-Host "" Write-Warn "Checking for newer versions..." @@ -1832,6 +1886,7 @@ function Invoke-UpdateWebStack { $needsPma = ($currentPmaVer -and $latestPmaVer -and ($currentPmaVer -ne 'unknown') -and ([version]$latestPmaVer -gt [version]$currentPmaVer)) Stop-WebStackServices + Start-Sleep -Seconds 2 Write-Host "" Write-Warn "Removing outdated installations..." @@ -1849,8 +1904,13 @@ function Invoke-UpdateWebStack { Invoke-CopyPhpDlls } if ($needsMariadb) { + Backup-MariaDbData + Remove-Item $MARIADB_PATH -Recurse -Force -ErrorAction SilentlyContinue Invoke-DownloadAndExtract $latestMariadbUrl $MARIADB_PATH "MariaDB" + + Restore-MariaDbData + Invoke-ConfigureMariaDb } if ($needsPma) { @@ -1866,27 +1926,212 @@ function Invoke-UpdateWebStack { Write-Ok "Update complete" - # Update config with new versions (preserve existing for components not updated) - $existingConfig = Get-Config - $versions = @{ - apache = $(if ($needsApache) { Get-ApacheVersion } else { $existingConfig.versions.apache }) - php = $(if ($needsPhp) { Get-PhpVersion } else { $existingConfig.versions.php }) - mariadb = $(if ($needsMariadb) { Get-MariaDbVersion } else { $existingConfig.versions.mariadb }) - phpmyadmin = $(if ($needsPma) { Get-PhpMyAdminVersion } else { $existingConfig.versions.phpmyadmin }) + Save-PostUpdateConfig $needsApache $needsPhp $needsMariadb $needsPma +} + +# ============================================================ +# FORCED UPDATE (offline — scans $TEMP_DOWNLOADS only) +# ============================================================ + +function Invoke-ForcedUpdate { + Write-Host "" + Write-Warn "Forced update (offline) — scanning $TEMP_DOWNLOADS for cached versions..." + Write-Host "" + + $zipFiles = Get-ChildItem -Path $TEMP_DOWNLOADS -Filter "*.zip" -ErrorAction SilentlyContinue + if (-not $zipFiles) { + Write-Err "No zip files found in $TEMP_DOWNLOADS" + return } - $pathEntries = Add-ToPath - # Offer Windows services if not already registered - if (-not (Test-ServicesInstalled)) { + # Collect ALL cached versions per component (not just the newest) + $apacheVersions = @() + $phpVersions = @() + $mariadbVersions = @() + $pmaVersions = @() + + foreach ($zip in $zipFiles) { + $name = $zip.BaseName + if ($name -like "*httpd*" -or $name -like "*apache*") { + $ver = Get-VersionFromZipName $name 'apache' + if ($ver) { $apacheVersions += @{ Path = $zip.FullName; Version = $ver } } + } + elseif ($name -like "*php-*" -and $name -notlike "*phpmyadmin*") { + $ver = Get-VersionFromZipName $name 'php' + if ($ver) { $phpVersions += @{ Path = $zip.FullName; Version = $ver } } + } + elseif ($name -like "*mariadb*") { + $ver = Get-VersionFromZipName $name 'mariadb' + if ($ver) { $mariadbVersions += @{ Path = $zip.FullName; Version = $ver } } + } + elseif ($name -like "*phpmyadmin*") { + $ver = Get-VersionFromZipName $name 'phpmyadmin' + if ($ver) { $pmaVersions += @{ Path = $zip.FullName; Version = $ver } } + } + } + + # Sort each by version descending + $apacheVersions = @($apacheVersions | Sort-Object { [version]$_.Version } -Descending) + $phpVersions = @($phpVersions | Sort-Object { [version]$_.Version } -Descending) + $mariadbVersions = @($mariadbVersions | Sort-Object { [version]$_.Version } -Descending) + $pmaVersions = @($pmaVersions | Sort-Object { [version]$_.Version } -Descending) + + # Get installed versions + $currentApacheVer = Get-ApacheVersion + $currentPhpVer = Get-PhpVersion + $currentMariadbVer = Get-MariaDbVersion + $currentPmaVer = Get-PhpMyAdminVersion + + # ---- Summary ---- + Write-Host "Cached versions in $TEMP_DOWNLOADS`:" -ForegroundColor White + Write-Host "" + + function Show-ComponentSummary($label, $installed, $cachedList) { + Write-Host "$label" -NoNewline -ForegroundColor White + Write-Host " — installed: " -NoNewline + if ($installed) { + Write-Host $installed.PadRight(7) -NoNewline -ForegroundColor Green + } else { + Write-Host "none " -NoNewline -ForegroundColor DarkGray + } + Write-Host " | cache: " -NoNewline + if ($cachedList.Count -eq 0) { + Write-Host "none" -ForegroundColor DarkGray + } else { + $labels = @($cachedList | ForEach-Object { $_.Version }) + Write-Host ($labels -join ", ") -ForegroundColor Cyan + } + } + + Show-ComponentSummary "Apache " $currentApacheVer $apacheVersions + Show-ComponentSummary "PHP " $currentPhpVer $phpVersions + Show-ComponentSummary "MariaDB " $currentMariadbVer $mariadbVersions + Show-ComponentSummary "phpMyAdmin" $currentPmaVer $pmaVersions + + # ---- Interactive selection ---- + $components = @( + @{ Name = 'Apache'; Installed = $currentApacheVer; Cached = $apacheVersions; Var = 'selectedApache' } + @{ Name = 'PHP'; Installed = $currentPhpVer; Cached = $phpVersions; Var = 'selectedPhp' } + @{ Name = 'MariaDB'; Installed = $currentMariadbVer; Cached = $mariadbVersions; Var = 'selectedMariadb' } + @{ Name = 'phpMyAdmin'; Installed = $currentPmaVer; Cached = $pmaVersions; Var = 'selectedPma' } + ) + + $selectedApache = $null + $selectedPhp = $null + $selectedMariadb = $null + $selectedPma = $null + + $anyChoice = $false + + foreach ($comp in $components) { + if ($comp.Cached.Count -eq 0) { continue } + + # If only one cached version and it matches installed, skip + if ($comp.Cached.Count -eq 1 -and $comp.Installed -and $comp.Cached[0].Version -eq $comp.Installed) { + continue + } + Write-Host "" - $svcChoice = Read-Host "Install as Windows services (auto-start on boot)? [y/N]" - if ($svcChoice -match "^[Yy]") { - Install-AsServices + Write-Host "$($comp.Name):" -ForegroundColor White + + for ($i = 0; $i -lt $comp.Cached.Count; $i++) { + $v = $comp.Cached[$i].Version + $tag = "" + $tagColor = "Cyan" + if ($comp.Installed) { + try { + if ([version]$v -gt [version]$comp.Installed) { $tag = " (newer)"; $tagColor = "Yellow" } + elseif ([version]$v -lt [version]$comp.Installed) { $tag = " (older)"; $tagColor = "DarkGray" } + else { $tag = " (current)"; $tagColor = "Green" } + } catch { } + } + Write-Host " [$($i + 1)] $v" -NoNewline -ForegroundColor Cyan + if ($tag) { + Write-Host $tag -ForegroundColor $tagColor + } else { + Write-Host "" + } + } + Write-Host " [S] skip" -ForegroundColor DarkGray + + $choice = Read-Host " Choose" + if ($choice -match '^[Ss]$' -or [string]::IsNullOrWhiteSpace($choice)) { + continue + } + try { + $idx = [int]$choice - 1 + if ($idx -ge 0 -and $idx -lt $comp.Cached.Count) { + Set-Variable -Name $comp.Var -Value $comp.Cached[$idx] + $anyChoice = $true + Write-Ok "$($comp.Name) → $($comp.Cached[$idx].Version)" + } else { + Write-Warn " Invalid choice — skipping $($comp.Name)" + } + } catch { + Write-Warn " Invalid choice — skipping $($comp.Name)" } } - # Save config with final state (including service registration) - Save-Config -InstallPath $BASE -Versions $versions -PathEntries $pathEntries -ServicesRegistered:(Test-ServicesInstalled) + if (-not $anyChoice) { + Write-Host "" + Write-Info "Nothing selected — no changes made." + return + } + + # ---- Apply ---- + $needsApache = ($null -ne $selectedApache) + $needsPhp = ($null -ne $selectedPhp) + $needsMariadb = ($null -ne $selectedMariadb) + $needsPma = ($null -ne $selectedPma) + + Stop-WebStackServices + Start-Sleep -Seconds 2 + + Write-Host "" + Write-Warn "Applying selected versions..." + + $prevProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + + if ($needsApache) { + Remove-Item $APACHE_PATH -Recurse -Force -ErrorAction SilentlyContinue + Invoke-ExtractZip $selectedApache.Path $APACHE_PATH "Apache" + Invoke-ConfigureApache + Write-Host "" + } + if ($needsPhp) { + Remove-Item $PHP_PATH -Recurse -Force -ErrorAction SilentlyContinue + Invoke-ExtractZip $selectedPhp.Path $PHP_PATH "PHP" + Invoke-ConfigurePhp + Invoke-FixSqliteDll + Invoke-CopyPhpDlls + Write-Host "" + } + if ($needsMariadb) { + Backup-MariaDbData + + Remove-Item $MARIADB_PATH -Recurse -Force -ErrorAction SilentlyContinue + Invoke-ExtractZip $selectedMariadb.Path $MARIADB_PATH "MariaDB" + + Restore-MariaDbData + + Invoke-ConfigureMariaDb + } + if ($needsPma) { + Remove-Item $PHPMYADMIN_PATH -Recurse -Force -ErrorAction SilentlyContinue + Invoke-ExtractZip $selectedPma.Path $PHPMYADMIN_PATH "phpMyAdmin" + Invoke-ConfigurePhpMyAdmin + } + + Start-WebStackServices + $ProgressPreference = $prevProgress + + if ($needsPma) { Invoke-ConfigurePmaStorage } + + Write-Host "" + Write-Host "Forced update complete" + + Save-PostUpdateConfig $needsApache $needsPhp $needsMariadb $needsPma } # ============================================================ @@ -2375,6 +2620,10 @@ while ($true) { Write-Host "" exit 0 } + "fu" { + if ($stackComplete) { Invoke-ForcedUpdate } + else { Write-Err "Stack not installed. Use 'I' to install first." } + } default { Write-Err "Command not recognised." } From 32889a1af448d84e733bbb3ab325419bc95f1eaa Mon Sep 17 00:00:00 2001 From: Simon Field Date: Tue, 16 Jun 2026 01:48:34 +1000 Subject: [PATCH 19/25] docs: document fu (forced update) command and version switching in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — assisted by Hymie (DaFa's AI) --- README_Win.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README_Win.md b/README_Win.md index a278703..5878edf 100644 --- a/README_Win.md +++ b/README_Win.md @@ -97,15 +97,16 @@ D Delete the web stack Q Quit ``` -| Key | Action | -| ----- | ------------------------------------------------------------------------- | -| **I** | Install the web stack (download + configure + start) | -| **U** | Update outdated components (compares installed vs latest online versions) | -| **R** | Restart Apache + MariaDB | -| **S** | Stop all services (offers to unregister if Windows services installed) | -| **T** | Start all services (offers Windows service registration if not installed) | -| **D** | Delete the web stack (preserves `www\` files and MariaDB data) | -| **Q** | Quit | +| Key | Action | +| ------ | ------------------------------------------------------------------------------------- | +| **I** | Install the web stack (download + configure + start) | +| **U** | Update outdated components (compares installed vs latest online versions) | +| **fu** | _(hidden)_ Forced update — switch components to any cached version from `%TEMP%\\webstack_downloads\\` without touching the network | +| **R** | Restart Apache + MariaDB | +| **S** | Stop all services (offers to unregister if Windows services installed) | +| **T** | Start all services (offers Windows service registration if not installed) | +| **D** | Delete the web stack (preserves `www\\` files and MariaDB data) | +| **Q** | Quit | ## After Installation @@ -252,7 +253,7 @@ Unlike most installers that hardcode version numbers, `getphp.ps1` dynamically r - **MariaDB** — Queries the MariaDB REST API (`/rest-api/mariadb/`), sorts stable releases by support policy (Rolling > LTS), then by version number. Constructs direct archive URL from version and filename (bypasses REST API redirector). Excludes debug-symbols-only zips. - **phpMyAdmin** — Scrapes the phpMyAdmin downloads page, finds all stable `all-languages.zip` files (excluding snapshots), picks the highest version -## Offline Mode & Download Caching +## Offline Mode, Download Caching & Version Switching Run the script with `-Offline` to skip all URL resolution and downloading: @@ -267,6 +268,8 @@ All downloaded files are cached permanently in `%TEMP%\webstack_downloads\`: - SQLite3 DLL zip — cached and reused - VC++ Redistributable installer (`.exe`) — cached and reused +Once you have multiple versions cached, the hidden **`fu`** (forced update) command lets you switch between them interactively without touching the network. Type `fu` at the dashboard prompt and you'll see a summary of installed vs cached versions, then choose which version to install per component — upgrades, downgrades, or snapshots. MariaDB databases are automatically backed up and restored across version changes. + ## Known Quirks & Fixes ### ARM64 / Snapdragon / Apple Silicon (Windows VM) From 5cd720f1e31db48b9cffd26e3e3c4feaefb9dcaa Mon Sep 17 00:00:00 2001 From: Simon Field Date: Tue, 16 Jun 2026 01:51:39 +1000 Subject: [PATCH 20/25] chore: bump version to 1.0.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — assisted by Hymie (DaFa's AI) --- getphp.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index baa94de..fe425bb 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -4,8 +4,8 @@ # Github: https://github.com/getphporg/getphp # Author: Simon Field (aka - DaFa) # License: MIT -# Date: 2026-06-13 -# Version: 1.0.4 +# Date: 2026-06-15 +# Version: 1.0.5 # ============================================================ #Requires -RunAsAdministrator From 86fcb656ad578b4fca5fd930aba384964cc70607 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 20 Jun 2026 00:27:51 +1000 Subject: [PATCH 21/25] fix: Get-VersionFromZipName PHP regex now handles RC releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex 'php-([\d.]+)-' required a dash immediately after the version number, but PHP RC filenames (e.g. php-8.5.8RC1-Win32-vs17-x64.zip) have the RC suffix appended directly with no separator. Changed to 'php-([\d.]+)(?:RC\d+)?-' to optionally match RC suffixes before the dash. — assisted by Hymie (DaFa's AI) --- getphp.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/getphp.ps1 b/getphp.ps1 index fe425bb..b5c66b9 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -119,7 +119,7 @@ function Get-VersionFromZipName([string]$filename, [string]$component) { $ver = $null switch ($component) { 'apache' { if ($filename -match 'httpd-([\d.]+)-') { $ver = $matches[1] } } - 'php' { if ($filename -match 'php-([\d.]+)-') { $ver = $matches[1] } } + 'php' { if ($filename -match 'php-([\d.]+)(?:RC\d+)?-') { $ver = $matches[1] } } 'mariadb' { if ($filename -match 'mariadb-([\d.]+)-') { $ver = $matches[1] } } 'phpmyadmin' { if ($filename -match 'phpMyAdmin-([\d.]+)') { $ver = $matches[1] } } } From 637a0be7682cf3dde7c3addc175515f3f61ba7f4 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 20 Jun 2026 00:40:31 +1000 Subject: [PATCH 22/25] fix: remove #Requires -RunAsAdministrator to restore custom admin message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerShell's #Requires fires before any script code executes, so the friendly custom admin error message in MAIN LOOP was dead code — users only saw PowerShell's terse default error. The custom check at line 2382 now handles admin detection with the nice instructional message. — assisted by Hymie (DaFa's AI) --- getphp.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/getphp.ps1 b/getphp.ps1 index b5c66b9..ec72156 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -7,7 +7,6 @@ # Date: 2026-06-15 # Version: 1.0.5 # ============================================================ -#Requires -RunAsAdministrator param( [switch]$Offline # Skip URL resolution — use pre-downloaded zips from TEMP From a438213204ab0474cb45833c22654b8457910df3 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 20 Jun 2026 00:42:21 +1000 Subject: [PATCH 23/25] =?UTF-8?q?docs:=20update=20README=20=E2=80=94=20for?= =?UTF-8?q?k=20URL,=20admin=20instruction,=20and=20program=20flow=20diagra?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — assisted by Hymie (DaFa's AI) --- README_Win.md | 209 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 13 deletions(-) diff --git a/README_Win.md b/README_Win.md index 5878edf..18b6a27 100644 --- a/README_Win.md +++ b/README_Win.md @@ -8,9 +8,11 @@ Launch your local PHP web stack on Windows 11 with a single PowerShell script. E ## Quick Start +Right-click PowerShell → Run as Administrator, then: + ```powershell -# Right-click PowerShell → Run as Administrator, then: -irm https://raw.githubusercontent.com/getphporg/getphp/HEAD/getphp.ps1 | iex + +irm https://raw.githubusercontent.com/dafa66/getphp/HEAD/getphp.ps1 | iex ``` Press **I** to install. That's it. @@ -97,22 +99,22 @@ D Delete the web stack Q Quit ``` -| Key | Action | -| ------ | ------------------------------------------------------------------------------------- | -| **I** | Install the web stack (download + configure + start) | -| **U** | Update outdated components (compares installed vs latest online versions) | +| Key | Action | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| **I** | Install the web stack (download + configure + start) | +| **U** | Update outdated components (compares installed vs latest online versions) | | **fu** | _(hidden)_ Forced update — switch components to any cached version from `%TEMP%\\webstack_downloads\\` without touching the network | -| **R** | Restart Apache + MariaDB | -| **S** | Stop all services (offers to unregister if Windows services installed) | -| **T** | Start all services (offers Windows service registration if not installed) | -| **D** | Delete the web stack (preserves `www\\` files and MariaDB data) | -| **Q** | Quit | +| **R** | Restart Apache + MariaDB | +| **S** | Stop all services (offers to unregister if Windows services installed) | +| **T** | Start all services (offers Windows service registration if not installed) | +| **D** | Delete the web stack (preserves `www\\` files and MariaDB data) | +| **Q** | Quit | ## After Installation | Question | Answer | | --------------------------- | -------------------------------------- | -| Where to put website files? | `%USERPROFILE%\getphp\www` | +| Where to put website files? | `%USERPROFILE%\getphp\www` | | How to test your PHP setup? | http://localhost/phpinfo.php | | Where to access phpMyAdmin? | http://localhost/phpmyadmin | | How to log into phpMyAdmin? | Username: `root` / Password: _(blank)_ | @@ -149,7 +151,10 @@ Example `config.json`: "mariadb": "12.3.2", "phpmyadmin": "5.2.3" }, - "path_entries": ["C:\\Users\\\\getphp\\php", "C:\\Users\\\\getphp\\mariadb\\bin"] + "path_entries": [ + "C:\\Users\\\\getphp\\php", + "C:\\Users\\\\getphp\\mariadb\\bin" + ] } ``` @@ -264,12 +269,190 @@ Run the script with `-Offline` to skip all URL resolution and downloading: Offline mode requires four pre-downloaded zip files in `%TEMP%\webstack_downloads\` (Apache, PHP, MariaDB, phpMyAdmin) — run the script online once to populate the cache, then subsequent installs skip downloads entirely. All downloaded files are cached permanently in `%TEMP%\webstack_downloads\`: + - Component zips (Apache, PHP, MariaDB, phpMyAdmin) — reused on re-install when the version hasn't changed - SQLite3 DLL zip — cached and reused - VC++ Redistributable installer (`.exe`) — cached and reused Once you have multiple versions cached, the hidden **`fu`** (forced update) command lets you switch between them interactively without touching the network. Type `fu` at the dashboard prompt and you'll see a summary of installed vs cached versions, then choose which version to install per component — upgrades, downgrades, or snapshots. MariaDB databases are automatically backed up and restored across version changes. +## Program Flow Diagram + +```mermaid +flowchart TD + %% ── Entry Point ── + START(["irm getphp.ps1 | iex"]) --> PARAM{"-Offline?"} + PARAM -->|"Yes"| OFFLINE_FLAG["Set $Offline = $true"] + PARAM -->|"No"| ADMIN + OFFLINE_FLAG --> ADMIN + + %% ── Pre-flight Guards ── + ADMIN{"Run as Admin?"} -->|"No"| EXIT_ADMIN["Exit: Admin required"] + ADMIN -->|"Yes"| ARCH{"x64 (AMD64)?"} + ARCH -->|"No"| EXIT_ARCH["Exit: ARM / 32-bit unsupported"] + ARCH -->|"Yes"| BANNER["Display Banner"] + + %% ── VC++ Redist Check (blocking) ── + BANNER --> VCREDIST{"VC++ Redist ≥ 14.51?"} + VCREDIST -->|"No"| VC_PROMPT["Offer install/update"] + VC_PROMPT -->|"Accept"| VC_INSTALL["Download & run VC++ installer"] + VC_INSTALL --> VC_REBOOT{"Reboot needed?"} + VC_REBOOT -->|"Yes"| VC_OFFER_REBOOT["Offer reboot"] + VC_OFFER_REBOOT -->|"Yes"| VC_REBOOT_NOW(["Restart-Computer -Force"]) + VC_OFFER_REBOOT -->|"No"| EXIT_REBOOT["Exit: re-run after reboot"] + VC_REBOOT -->|"No"| VCREDIST + VC_PROMPT -->|"Decline"| EXIT_VC["Exit: VC++ required"] + VCREDIST -->|"Yes"| CONFIG + + %% ── Config & Path Resolution ── + CONFIG{"Saved config exists?"} + CONFIG -->|"Yes"| VALIDATE["Validate saved path
— no spaces
— derive all sub-paths"] + VALIDATE -->|"Invalid"| EXIT_PATH["Exit: fix config"] + VALIDATE -->|"Valid"| SYNC["Sync service state
with reality"] + CONFIG -->|"No (first run)"| PROMPT_PATH["Prompt for install path
default: ~/getphp"] + PROMPT_PATH --> SAVE_CONFIG["Save config.json"] + SAVE_CONFIG --> DERIVE["Derive all sub-paths"] + SYNC --> DASHBOARD + DERIVE --> DASHBOARD + + %% ── Main Dashboard Loop ── + DASHBOARD["**Show Dashboard**
Stack status · Service status
Prerequisites · Commands"] + DASHBOARD --> CHECK_STACK{"Test-StackComplete
(all 4 components?)"} + CHECK_STACK --> READ_CMD["Read-Host 'Enter command'"] + + %% ── Command Router ── + READ_CMD --> ROUTE{"$cmd"} + ROUTE -->|"I (not installed)"| INSTALL + ROUTE -->|"I (installed)"| ERR_I["Error: already installed"] + ROUTE -->|"U (installed)"| UPDATE + ROUTE -->|"U (not installed)"| ERR_U["Error: not installed"] + ROUTE -->|"R"| RESTART + ROUTE -->|"S"| STOP + ROUTE -->|"T"| START + ROUTE -->|"D"| DELETE + ROUTE -->|"fu"| FORCED_UPDATE + ROUTE -->|"Q"| QUIT(["Write-Ok 'Goodbye!' → exit 0"]) + ROUTE -->|"other"| ERR_CMD["Error: command not recognised"] + ERR_I --> PAUSE + ERR_U --> PAUSE + ERR_CMD --> PAUSE + PAUSE["Pause"] --> DASHBOARD + + %% ══════════ INSTALL SUB-FLOW ══════════ + INSTALL["**Invoke-InstallWebStack**"] --> INST_VC{"VC++ OK?"} + INST_VC -->|"No"| INST_FIX_VC["Offer install → install or abort"] + INST_FIX_VC -->|"Abort"| DASHBOARD + INST_FIX_VC -->|"Done"| INST_DIRS + INST_VC -->|"Yes"| INST_DIRS["Create directories
base · www · logs · temp_downloads"] + + INST_DIRS --> INST_MODE{"$Offline?"} + INST_MODE -->|"Online"| INST_RESOLVE["Resolve latest URLs
Apache · PHP · MariaDB · phpMyAdmin"] + INST_RESOLVE --> INST_DL_EACH["For each component:
skip if same version already installed
else Invoke-DownloadAndExtract"] + INST_MODE -->|"Offline"| INST_SCAN["Scan $TEMP_DOWNLOADS for zips
(needs 4: httpd*, php-*, mariadb*, phpmyadmin*)"] + INST_SCAN --> INST_OFFLINE_EXT["Identify & extract each zip"] + + INST_DL_EACH --> INST_DLL["Copy PHP dependency DLLs
to Apache bin\"] + INST_OFFLINE_EXT --> INST_DLL + INST_DLL --> INST_CFG_APACHE["Configure Apache
httpd.conf · mod_rewrite · phpMyAdmin alias"] + INST_CFG_APACHE --> INST_CFG_PHP["Configure PHP
php.ini · extensions · error log · OPCache"] + INST_CFG_PHP --> INST_SQLITE["Fix SQLite3 DLL
(VS17 bundled version is broken)"] + + INST_SQLITE --> INST_DB_BACKUP{"Orphaned data_backup?"} + INST_DB_BACKUP -->|"Yes"| INST_RESTORE["Offer restore → move to mariadb/data"] + INST_DB_BACKUP -->|"No"| INST_CFG_MDB + INST_RESTORE --> INST_CFG_MDB["Configure MariaDB
my.ini · data init · blank root password"] + + INST_CFG_MDB --> INST_PMA{"Offline mode?"} + INST_PMA -->|"Online"| INST_PMA_DL["Download & extract phpMyAdmin"] + INST_PMA_DL --> INST_CFG_PMA + INST_PMA -->|"Offline"| INST_CFG_PMA["Configure phpMyAdmin
config.inc.php · blowfish secret"] + + INST_CFG_PMA --> INST_PHPINFO["Create phpinfo.php test file"] + INST_PHPINFO --> INST_PATH["Add PHP + MariaDB to user PATH"] + INST_PATH --> INST_SVC{"Install as Windows services?"} + INST_SVC -->|"Yes"| INST_SVC_REG["Install-AsServices
getPHP_Apache
getPHP_MariaDB"] + INST_SVC -->|"No"| INST_START + INST_SVC_REG --> INST_START["Start services
(service or process mode)"] + + INST_START --> INST_PMA_STOR["Configure phpMyAdmin storage
(pma_ tables)"] + INST_PMA_STOR --> INST_DONE["Save config.json
Display 'Installation Complete!'"] + INST_DONE --> PAUSE + + %% ══════════ UPDATE SUB-FLOW ══════════ + UPDATE["**Invoke-UpdateWebStack**"] --> UPD_RESOLVE["Resolve latest URLs for all 4"] + UPD_RESOLVE --> UPD_COMPARE["Compare installed vs latest versions"] + UPD_COMPARE --> UPD_OUTDATED{"Any outdated?"} + UPD_OUTDATED -->|"No"| UPD_OK["Write-Ok 'Stack is up to date'"] + UPD_OK --> PAUSE + UPD_OUTDATED -->|"Yes"| UPD_CONFIRM{"Confirm update?"} + UPD_CONFIRM -->|"No"| PAUSE + UPD_CONFIRM -->|"Yes"| UPD_STOP["Stop all services"] + UPD_STOP --> UPD_EACH["For each outdated component:
Remove old → Download new → Configure"] + UPD_EACH --> UPD_MDB_CHK{"MariaDB updated?"} + UPD_MDB_CHK -->|"Yes"| UPD_MDB_BACKUP["Backup data → Restore data"] + UPD_MDB_CHK -->|"No"| UPD_START + UPD_MDB_BACKUP --> UPD_START["Start services"] + UPD_START --> UPD_PMA_CHK{"phpMyAdmin updated?"} + UPD_PMA_CHK -->|"Yes"| UPD_PMA_STOR["Configure phpMyAdmin storage"] + UPD_PMA_CHK -->|"No"| UPD_SAVE + UPD_PMA_STOR --> UPD_SAVE["Save-PostUpdateConfig"] + UPD_SAVE --> PAUSE + + %% ══════════ DELETE SUB-FLOW ══════════ + DELETE["**Invoke-DeleteWebStack**"] --> DEL_CONFIRM{"Type 'DELETE' to confirm?"} + DEL_CONFIRM -->|"No"| DEL_ABORT["Nothing deleted"] + DEL_ABORT --> PAUSE + DEL_CONFIRM -->|"Yes"| DEL_STOP["Stop all services"] + DEL_STOP --> DEL_BACKUP["Backup mariadb\\data\\ → data_backup\\"] + DEL_BACKUP --> DEL_EXISTS{"Existing backup?"} + DEL_EXISTS -->|"Yes"| DEL_RENAME["Timestamp old backup
data_backup_YYYYMMDD_HHmmss"] + DEL_EXISTS -->|"No"| DEL_REMOVE + DEL_RENAME --> DEL_REMOVE["Remove: Apache · PHP · MariaDB · phpMyAdmin · logs"] + DEL_REMOVE --> DEL_SVC["Unregister Windows services"] + DEL_SVC --> DEL_PATH["Remove from user PATH"] + DEL_PATH --> DEL_CONFIG["Clear-Config"] + DEL_CONFIG --> PAUSE + + %% ══════════ RESTART / STOP / START ══════════ + RESTART["Stop-WebStackServices → wait 2s → Start-WebStackServices"] --> PAUSE + STOP --> STOP_CHK{"Services registered?"} + STOP_CHK -->|"Yes"| STOP_OFFER["Offer to unregister"] + STOP_OFFER -->|"Yes"| STOP_UNREG["Remove-Services → Save config: false"] + STOP_OFFER -->|"No"| STOP_EXEC + STOP_CHK -->|"No"| STOP_EXEC["Stop-WebStackServices"] + STOP_UNREG --> PAUSE + STOP_EXEC --> PAUSE + START --> START_SVC["Request-ServiceRegistration
(offer if not yet registered)"] + START_SVC --> START_START["Start-WebStackServices"] + START_START --> PAUSE + + %% ══════════ FORCED UPDATE (offline) ══════════ + FORCED_UPDATE["**Invoke-ForcedUpdate**"] --> FU_SCAN["Scan $TEMP_DOWNLOADS for cached zips"] + FU_SCAN --> FU_SHOW["Show installed vs cached versions"] + FU_SHOW --> FU_PICK["User picks version per component"] + FU_PICK --> FU_STOP["Stop services"] + FU_STOP --> FU_EACH["For each changed component:
Remove old → Extract cached zip → Configure"] + FU_EACH --> FU_START["Start services"] + FU_START --> FU_SAVE["Save config with new versions"] + FU_SAVE --> PAUSE + + %% ── Styling ── + style START fill:#0f0,color:#000 + style QUIT fill:#f66,color:#fff + style EXIT_ADMIN fill:#f66,color:#fff + style EXIT_ARCH fill:#f66,color:#fff + style EXIT_VC fill:#f66,color:#fff + style EXIT_PATH fill:#f66,color:#fff + style DASHBOARD fill:#39f,color:#fff + style INSTALL fill:#3c3,color:#fff + style UPDATE fill:#3c3,color:#fff + style DELETE fill:#f93,color:#000 + style FORCED_UPDATE fill:#93f,color:#fff + style ERR_I fill:#999,color:#fff + style ERR_U fill:#999,color:#fff + style ERR_CMD fill:#999,color:#fff +``` + ## Known Quirks & Fixes ### ARM64 / Snapdragon / Apple Silicon (Windows VM) From 0c10dde4e139e3dba0048cb8d774463293119094 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 20 Jun 2026 00:46:52 +1000 Subject: [PATCH 24/25] chore: bump to 1.0.6, add pro tag to banner, remove banner from VC++ check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — assisted by Hymie (DaFa's AI) --- getphp.ps1 | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index ec72156..63b0640 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -4,8 +4,8 @@ # Github: https://github.com/getphporg/getphp # Author: Simon Field (aka - DaFa) # License: MIT -# Date: 2026-06-15 -# Version: 1.0.5 +# Date: 2026-06-20 +# Version: 1.0.6 # ============================================================ param( @@ -22,7 +22,7 @@ $BANNER_ART = @' │ __ _ ___| |_| _ \| | | | _ \ │ │ / _` |/ _ \ __| |_) | |_| | |_) | │ │ | (_| | __/ |_| __/| _ | __/ │ -│ \__, |\___|\__|_| |_| |_|_| │ +│ \__, |\___|\__|_| |_| |_|_| pro │ │ |___/ www.getPHP.org │ └────────────────────────────────────┘ '@ @@ -2411,10 +2411,6 @@ if ($cpu_arch -ne 'AMD64') { exit 1 } -# ---- Banner ---- -Write-Host $BANNER_ART -ForegroundColor Cyan -Write-Host "" - # ---- VC++ Redistributable: system prerequisite (BLOCKING) ---- Write-Host "" Write-Host "========================================" -ForegroundColor White From d23edf1376e3edd8ebfaaddb2f1a1bbfb3992795 Mon Sep 17 00:00:00 2001 From: Simon Field Date: Sat, 20 Jun 2026 00:53:40 +1000 Subject: [PATCH 25/25] refactor: suppress VC++ section header when prerequisite already met MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When VC++ is installed, the while loop is skipped and the user goes straight to the dashboard — no system prerequisite noise on healthy systems. — assisted by Hymie (DaFa's AI) --- getphp.ps1 | 5 ----- 1 file changed, 5 deletions(-) diff --git a/getphp.ps1 b/getphp.ps1 index 63b0640..fa43817 100644 --- a/getphp.ps1 +++ b/getphp.ps1 @@ -2412,11 +2412,6 @@ if ($cpu_arch -ne 'AMD64') { } # ---- VC++ Redistributable: system prerequisite (BLOCKING) ---- -Write-Host "" -Write-Host "========================================" -ForegroundColor White -Write-Host " SYSTEM PREREQUISITE CHECK" -ForegroundColor White -Write-Host "========================================" -ForegroundColor White -Write-Host "" while (-not (Test-VcRedistInstalled)) { Write-Warn "Visual C++ Redistributable (VS 2017-2026) x64 is required."