# Easy WP Bootstrap — Windows (PowerShell + Herd) — local wp-cli.phar via php.exe Param( [Parameter(Mandatory=$true)][string]$ProjectName, [string]$AdminUser, [string]$AdminEmail, [string]$ThemeStarterRepo, [string]$HerdWorkspace, [string]$LocalTld, [string]$MysqlHost, [string]$MysqlPort, [string]$MysqlRootUser, [string]$MysqlRootPass ) $ErrorActionPreference = 'Stop' $env:WP_CLI_STRICT_ARGS_MODE = '1' # ---- Load rc (CLI > rc > hard default) ---- $RcFile = Join-Path $HOME ".wp-bootstraprc" $Rc = @{} if (Test-Path $RcFile) { Get-Content $RcFile | ForEach-Object { $line = $_.Trim() if ($line -match '^\s*#' -or $line -match '^\s*$') { return } if ($line -match '^\s*([^=]+)=(.+)$') { $key = $matches[1].Trim() $val = $matches[2].Trim('"'' ') $val = $val -replace '\$HOME', [Regex]::Escape($HOME) $val = $val -replace '^~', [Regex]::Escape($HOME) $val = $val -replace '\\\\','\' $Rc[$key] = $val } } } function RcOr([string]$Key,[string]$Fallback){ if($Rc.ContainsKey($Key) -and $Rc[$Key]){$Rc[$Key]}else{$Fallback} } if (-not $AdminUser) { $AdminUser = RcOr "DEFAULT_ADMIN_USER" "vdidev" } if (-not $AdminEmail) { $AdminEmail = RcOr "DEFAULT_ADMIN_EMAIL" "dev@vincentdesign.ca" } if (-not $ThemeStarterRepo) { $ThemeStarterRepo = RcOr "THEME_STARTER_REPO" "https://github.com/WordPress/twentytwentyfour" } if (-not $HerdWorkspace) { $HerdWorkspace = RcOr "HERD_WORKSPACE" "$HOME\Herd" } if (-not $LocalTld) { $LocalTld = RcOr "LOCAL_TLD" "test" } if (-not $MysqlHost) { $MysqlHost = RcOr "MYSQL_HOST" "127.0.0.1" } if (-not $MysqlPort) { $MysqlPort = RcOr "MYSQL_PORT" "3306" } if (-not $MysqlRootUser) { $MysqlRootUser = RcOr "MYSQL_ROOT_USER" "root" } if (-not $MysqlRootPass) { $MysqlRootPass = RcOr "MYSQL_ROOT_PASS" "" } # ---- Helpers ---- function Slugify([string]$s){ ($s.ToLower() -replace '[^a-z0-9]+','-').Trim('-') } function RandPass(){ $b = New-Object 'System.Byte[]' 48 (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($b) $raw = [Convert]::ToBase64String($b) -replace '[^a-zA-Z0-9]','' while($raw.Length -lt 24){ $b2 = New-Object 'System.Byte[]' 16 (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($b2) $raw += ([Convert]::ToBase64String($b2) -replace '[^a-zA-Z0-9]','') } $raw.Substring(0,24) } # Resolve script folder + absolute phar $ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } $PharFullPath = Join-Path $ScriptRoot 'wp-cli.phar' if (!(Test-Path $PharFullPath)) { throw "wp-cli.phar not found at: $PharFullPath" } # Resolve Herd php.exe (parse php.bat or derive from php -v → $HOME\.config\herd\bin\\php.exe) function Resolve-PhpExe { $phpCmd = Get-Command php -ErrorAction SilentlyContinue if ($phpCmd -and ($phpCmd.Source -match '\.bat$') -and (Test-Path $phpCmd.Source)) { $bat = Get-Content -Raw $phpCmd.Source $m = [regex]::Match($bat, '(["'']?)(%[^"''\r\n]+%|[A-Za-z]:\\[^"''\r\n]+?\\php\.exe)\1') if ($m.Success) { $raw = $m.Groups[2].Value $exp = [Environment]::ExpandEnvironmentVariables($raw) if (Test-Path $exp) { return $exp } } } try { $verOut = & cmd.exe /d /s /c 'php -v' 2>&1 $verLine = ($verOut | Select-Object -First 1) $m2 = [regex]::Match($verLine, 'PHP\s+(\d+\.\d+\.\d+)') if ($m2.Success) { $ver = $m2.Groups[1].Value $cand = Join-Path $HOME ".config\herd\bin\$ver\php.exe" if (Test-Path $cand) { return $cand } } } catch {} $whereExe = (& where.exe php 2>$null) -split "`r?`n" | Where-Object { $_ -match '\.exe$' } | Select-Object -First 1 if ($whereExe -and (Test-Path $whereExe)) { return $whereExe } throw "Could not resolve a real php.exe (expected like $HOME\.config\herd\bin\\php.exe)." } $PhpExe = Resolve-PhpExe # ---- WP-CLI wrapper: space-safe (runs php from script folder) ---- function Invoke-WP { [CmdletBinding(PositionalBinding=$false)] param( [Parameter(ValueFromRemainingArguments=$true)] [string[]]$WpArgs ) # WordPress installation directory (do not change) $wpPath = ((Get-Location).Path) -replace '\\','/' # Always run PHP from the folder that contains wp-cli.phar to avoid spaces in the script path $orig = Get-Location try { Push-Location $ScriptRoot # Use -f and -- to separate PHP options from WP-CLI args # Format executed: php -f wp-cli.phar -- --path= $all = @("-f", "wp-cli.phar", "--", "--path=$wpPath") + $WpArgs # DEBUG (optional): uncomment to see exactly what's executed # Write-Host ">> $PhpExe $($all -join ' ') (cwd=$((Get-Location).Path))" $out = & $PhpExe @all 2>&1 # DEBUG (optional): uncomment to see full output # $out | Out-Host if ($LASTEXITCODE -ne 0) { throw "WP-CLI failed (exit $LASTEXITCODE): $PhpExe $($all -join ' ') (cwd=$((Get-Location).Path))" } return $out } finally { Pop-Location | Out-Null } } # ---- Compute basics ---- $Slug = Slugify $ProjectName $ProjectDir = Join-Path $HerdWorkspace $Slug $LocalUrl = "http://$Slug.$LocalTld" $DbName = "wp_$Slug" $DbUser = $DbName $DbPass = RandPass $AdminPass = RandPass Write-Host "Creating project at $ProjectDir...`n" New-Item -ItemType Directory -Force -Path $ProjectDir | Out-Null Set-Location $ProjectDir # Sanity print $pathShown = ((Get-Location).Path -replace '\\','/') Write-Host "WP-CLI php: $PhpExe" Write-Host "WP-CLI phar: $PharFullPath" Write-Host "WP-CLI --path: --path=$pathShown" Invoke-WP "cli" "version" # ---- WP Core ---- Write-Host "`nDownloading WordPress core..." Invoke-WP "core" "download" "--force" # ---------- Database (DBngin/MySQL) ---------- Write-Host "`nCreating MySQL DB and user..." # Build root arg list $mysqlArgsRoot = @("-h", $MysqlHost, "-P", $MysqlPort, "-u", $MysqlRootUser) if ($MysqlRootPass -ne "") { $mysqlArgsRoot += "-p$MysqlRootPass" } # Sanity: confirm root can connect $null = & mysql.exe @mysqlArgsRoot -e "SELECT VERSION();" 2>$null if ($LASTEXITCODE -ne 0) { throw ("Cannot connect to MySQL as root ({0}@{1}:{2}). Check MYSQL_ROOT_* in your rc file." -f $MysqlRootUser, $MysqlHost, $MysqlPort) } # Build SQL without any escaping sequences in PowerShell strings. # Use a real backtick char for MySQL identifiers: $bt = [char]96 # ` $dbIdent = "$bt$DbName$bt" $sqlParts = @( "CREATE DATABASE IF NOT EXISTS $dbIdent CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;", "", "CREATE USER IF NOT EXISTS '$DbUser'@'localhost' IDENTIFIED BY '$DbPass';", "CREATE USER IF NOT EXISTS '$DbUser'@'127.0.0.1' IDENTIFIED BY '$DbPass';", "CREATE USER IF NOT EXISTS '$DbUser'@'%' IDENTIFIED BY '$DbPass';", "", "GRANT ALL PRIVILEGES ON $dbIdent.* TO '$DbUser'@'localhost';", "GRANT ALL PRIVILEGES ON $dbIdent.* TO '$DbUser'@'127.0.0.1';", "GRANT ALL PRIVILEGES ON $dbIdent.* TO '$DbUser'@'%';", "FLUSH PRIVILEGES;" ) $tmpSql = [System.IO.Path]::GetTempFileName() [System.IO.File]::WriteAllLines($tmpSql, $sqlParts, [System.Text.Encoding]::UTF8) # Feed the SQL file to mysql via cmd redirection (reliable on Windows) function Quote-ForCmd([string]$s){ '"' + ($s -replace '"','""') + '"' } $cmdLine = "mysql.exe " + ( ($mysqlArgsRoot | ForEach-Object { Quote-ForCmd $_ }) -join ' ' ) + " < " + (Quote-ForCmd $tmpSql) # DEBUG (optional): uncomment to see exactly what's run # Write-Host ">> $cmdLine" & cmd.exe /d /s /c $cmdLine $exit = $LASTEXITCODE # Clean up the temp file regardless Remove-Item -Force $tmpSql -ErrorAction SilentlyContinue if ($exit -ne 0) { throw "MySQL SQL error while creating DB/user. Verify root creds and server." } # Verify the NEW user can actually select the DB (fail fast if not) $mysqlArgsUser = @("-h", $MysqlHost, "-P", $MysqlPort, "-u", $DbUser, "-p$DbPass", "-D", $DbName) $null = & mysql.exe @mysqlArgsUser -e "SELECT 1;" 2>$null if ($LASTEXITCODE -ne 0) { throw ("New MySQL user cannot access DB '{0}' via {1}:{2}. Host match or privileges issue — check users and grants." -f $DbName, $MysqlHost, $MysqlPort) } # ---- Config + Install ---- Invoke-WP "config" "create" "--dbname=$DbName" "--dbuser=$DbUser" "--dbpass=$DbPass" "--dbhost=$($MysqlHost):$($MysqlPort)" "--force" Invoke-WP "config" "shuffle-salts" Invoke-WP "core" "install" "--url=$LocalUrl" "--title=$ProjectName" "--admin_user=$AdminUser" "--admin_password=$AdminPass" "--admin_email=$AdminEmail" Invoke-WP "option" "update" "siteurl" $LocalUrl Invoke-WP "option" "update" "home" $LocalUrl # ---- Pages & Reading ---- Write-Host "Creating Home/News pages and setting permalinks..." $homeId = (Invoke-WP "post" "create" "--post_type=page" "--post_status=publish" "--post_title=Home" "--porcelain" | Select-Object -Last 1).Trim() $newsId = (Invoke-WP "post" "create" "--post_type=page" "--post_status=publish" "--post_title=News" "--porcelain" | Select-Object -Last 1).Trim() Invoke-WP "option" "update" "show_on_front" "page" Invoke-WP "option" "update" "page_on_front" $homeId Invoke-WP "option" "update" "page_for_posts" $newsId Invoke-WP "rewrite" "structure" "/%postname%/" Invoke-WP "rewrite" "flush" "--hard" # --- Ensure .htaccess exists (for Apache/permalinks) --- $htFile = Join-Path $ProjectDir ".htaccess" if (!(Test-Path $htFile)) { $ht = @" # BEGIN WordPress RewriteEngine On RewriteBase / RewriteRule ^index\.php$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.php [L] # END WordPress "@ Set-Content -Path $htFile -Value $ht -Encoding ASCII Write-Host "`nCreated default .htaccess" } # ---- Theme ---- Write-Host "`nCloning starter theme..." $tmp = ".starter-tmp" if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp } git clone --depth=1 $ThemeStarterRepo $tmp | Out-Null $ThemeDir = "wp-content\themes\$Slug" if (Test-Path $ThemeDir) { Remove-Item -Recurse -Force $ThemeDir } New-Item -ItemType Directory -Force -Path (Split-Path $ThemeDir) | Out-Null Move-Item $tmp $ThemeDir if (Test-Path "$ThemeDir\.git") { Remove-Item -Recurse -Force "$ThemeDir\.git" } # --- Stamp Theme Name + Text Domain into style.css --- $styleCss = Join-Path $ThemeDir "style.css" $TextDomain = "$Slug" # matches your theme folder name if (Test-Path $styleCss) { $css = Get-Content $styleCss -Raw # Replace existing "Theme Name:" line (supports plain or "* Theme Name:" formats) $css = [regex]::Replace( $css, '(?im)^(?:\s*\*\s*)?Theme\s+Name\s*:\s*.*$', " * Theme Name: $ProjectName" ) Set-Content -Path $styleCss -Value $css -Encoding UTF8 Write-Host "`nUpdated style.css header with Theme Name: $ProjectName`n" } Invoke-WP "theme" "activate" "$Slug" Push-Location $ThemeDir # ---- Init theme dependencies and build Tailwind ---- Write-Host "`nInstalling theme dependencies and building assets..." composer install 2>$null npm install 2>$null npm run build 2>$null # ---- Init new git repo ---- Write-Host "`nInitializing new git repo for theme..." git init -b main | Out-Null git add -A git commit -m "feat: bootstrap ${ProjectName} theme from starter" | Out-Null Pop-Location # ---------- Plugins (optional) ---------- # Look for plugins.json in the project, then fall back to script folder $PluginsFileProject = Join-Path $ProjectDir "plugins.json" $PluginsFileScript = Join-Path $ScriptRoot "plugins.json" $PluginsFile = if (Test-Path $PluginsFileProject) { $PluginsFileProject } elseif (Test-Path $PluginsFileScript) { $PluginsFileScript } else { $null } if ($PluginsFile) { try { Write-Host "`nInstalling plugins from $PluginsFile..." $plugins = Get-Content $PluginsFile -Raw | ConvertFrom-Json foreach ($plugin in $plugins) { if ($plugin.zip) { $args = @("plugin","install",$plugin.zip,"--force") if ($plugin.activate -eq $true) { $args += "--activate" } Invoke-WP @args } elseif ($plugin.slug) { $args = @("plugin","install",$plugin.slug,"--force") if ($plugin.version) { $args += "--version=$($plugin.version)" } if ($plugin.activate -eq $true) { $args += "--activate" } Invoke-WP @args } } } catch { Write-Host "`nPlugin install error: $_" } } else { Write-Host "`nNo plugins.json found in project or script folder; skipping plugins." } # ---- Summary ---- $Summary = @" Easy WP Bootstrap — Summary =========================== Project: $ProjectName Slug: $Slug Folder: $ProjectDir Theme Dir: $ThemeDir DB Host: $MysqlHost DB Port: $MysqlPort DB Name: $DbName DB User: $DbUser DB Pass: $DbPass Local URL: $LocalUrl Local Admin URL: $LocalUrl/wp-admin/ WP Admin Email: $AdminEmail WP Admin User: $AdminUser WP Admin Pass: $AdminPass "@ $SummaryFile = Join-Path $ProjectDir "bootstrap-summary.txt" $Summary | Out-File -FilePath $SummaryFile -Encoding utf8 Write-Host "`n`n$Summary" Write-Host "`nSaved summary to $SummaryFile"