do/_do.sh
Austin Ginder 046239a761 🚀 RELEASE: v1.4
2025-09-19 18:42:21 -04:00

6989 lines
266 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# ----------------------------------------------------
# Command: _do
# Description: A collection of useful command-line utilities for managing WordPress sites.
# Author: Austin Ginder
# License: MIT
# ----------------------------------------------------
# --- Global Variables ---
CAPTAINCORE_DO_VERSION="1.4"
GUM_VERSION="0.14.4"
CWEBP_VERSION="1.5.0"
RCLONE_VERSION="1.69.3"
GIT_VERSION="2.50.0"
GUM_CMD=""
CWEBP_CMD=""
IDENTIFY_CMD=""
WP_CLI_CMD=""
RESTIC_CMD=""
DISEMBARK_CMD=""
# --- Helper Functions ---
# ----------------------------------------------------
# Intelligently finds or creates a private directory.
# Sets a global variable CAPTAINCORE_PRIVATE_DIR and echoes the path.
# ----------------------------------------------------
function _get_private_dir() {
# Return immediately if already found
if [[ -n "$CAPTAINCORE_PRIVATE_DIR" ]]; then
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
local wp_root=""
local parent_dir=""
# --- Tier 1: Preferred WP-CLI Method (if in a WP directory) ---
# Check if wp-cli is set up and if we are in a WP installation.
if setup_wp_cli && "$WP_CLI_CMD" core is-installed --quiet 2>/dev/null; then
local wp_config_path
wp_config_path=$("$WP_CLI_CMD" config path --quiet 2>/dev/null)
if [ -n "$wp_config_path" ] && [ -f "$wp_config_path" ]; then
wp_root=$(dirname "$wp_config_path")
parent_dir=$(dirname "$wp_root")
# --- WPE Specific Checks ---
# A. Check for _wpeprivate inside the WP root directory first (most common).
if [ -d "${wp_root}/_wpeprivate" ]; then
CAPTAINCORE_PRIVATE_DIR="${wp_root}/_wpeprivate"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# B. Check for _wpeprivate in the parent directory.
if [ -d "${parent_dir}/_wpeprivate" ]; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/_wpeprivate"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# --- Standard Checks (relative to WP root's parent) ---
# Check for a standard ../private directory
if [ -d "${parent_dir}/private" ]; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/private"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Try to create a ../private directory, suppressing errors
if mkdir -p "${parent_dir}/private" 2>/dev/null; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/private"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Fallback to ../tmp if it exists
if [ -d "${parent_dir}/tmp" ]; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/tmp"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
fi
fi
# --- Tier 2: Manual Fallback (if WP-CLI fails or not in a WP install) ---
local current_dir
current_dir=$(pwd)
# WPE check in current directory
if [ -d "${current_dir}/_wpeprivate" ]; then
CAPTAINCORE_PRIVATE_DIR="${current_dir}/_wpeprivate"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Relative private check
if [ -d "../private" ]; then
CAPTAINCORE_PRIVATE_DIR=$(cd ../private && pwd)
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Attempt to create relative private, suppressing errors
if mkdir -p "../private" 2>/dev/null; then
CAPTAINCORE_PRIVATE_DIR=$(cd ../private && pwd)
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Relative tmp check
if [ -d "../tmp" ]; then
CAPTAINCORE_PRIVATE_DIR=$(cd ../tmp && pwd)
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# --- Tier 3: Last Resort Fallback to Home Directory ---
# Suppress errors in case $HOME is not writable
if mkdir -p "$HOME/private" 2>/dev/null; then
CAPTAINCORE_PRIVATE_DIR="$HOME/private"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
echo "Error: Could not find or create a suitable private, writable directory." >&2
return 1
}
# ----------------------------------------------------
# Checks for and installs 'disembark' if not present.
# Prioritizes standard PATH, then a DISEMBARK_DEV_PATH env var.
# ----------------------------------------------------
function setup_disembark() {
# Return if already found
if [[ -n "$DISEMBARK_CMD" ]]; then
return 0
fi
# 1. Check if 'disembark' is in the standard PATH
if command -v disembark &> /dev/null; then
echo " - Found 'disembark' in system PATH." >&2
DISEMBARK_CMD="disembark"
return 0
fi
# 2. Check for a user-defined development path via environment variable
if [[ -n "$DISEMBARK_DEV_PATH" && -f "$DISEMBARK_DEV_PATH" && -x "$DISEMBARK_DEV_PATH" ]]; then
echo " - Found development version of 'disembark' via DISEMBARK_DEV_PATH." >&2
DISEMBARK_CMD="$DISEMBARK_DEV_PATH"
return 0
fi
# 3. If not found, attempt to install it system-wide
echo " Required tool 'disembark' not found. Attempting to install system-wide..." >&2
# Check for dependencies needed for the installation
if ! command -v wget &>/dev/null || ! command -v sudo &>/dev/null; then
echo " Error: 'wget' and 'sudo' are required to install disembark." >&2
return 1
fi
local temp_file
temp_file=$(mktemp)
echo " - Downloading disembark.phar..."
if ! wget -q "https://github.com/DisembarkHost/disembark-cli/raw/main/disembark.phar" -O "$temp_file"; then
echo " Error: Failed to download disembark.phar." >&2
rm -f "$temp_file"
return 1
fi
chmod +x "$temp_file"
echo " - Moving to /usr/local/bin/disembark. You may be prompted for your password." >&2
if ! sudo mv "$temp_file" /usr/local/bin/disembark; then
echo " Error: Failed to move disembark.phar to /usr/local/bin/." >&2
rm -f "$temp_file"
return 1
fi
echo " 'disembark' installed successfully." >&2
DISEMBARK_CMD="/usr/local/bin/disembark"
return 0
}
# ----------------------------------------------------
# Checks for and installs Playwright and its browser dependencies.
# Sets a global variable PLAYWRIGHT_READY on success.
# ----------------------------------------------------
function setup_playwright() {
# Return if already checked and ready
if [[ -n "$PLAYWRIGHT_READY" ]]; then
return 0
fi
# --- Pre-flight Checks ---
if ! command -v node &>/dev/null; then
echo "❌ Error: Node.js is required for Playwright." >&2
return 1
fi
if ! command -v npm &>/dev/null; then
echo "❌ Error: npm is required to install Playwright." >&2
return 1
fi
# --- Check for Browser Executable ---
# This is a more robust check. require('playwright') can succeed,
# but executablePath() will fail if browsers aren't installed.
if node -e "require('playwright').chromium.executablePath()" >/dev/null 2>&1; then
echo " - ✅ Playwright and required browsers are already installed."
PLAYWRIGHT_READY=true
return 0
fi
# --- Installation ---
echo " - ⚠️ Playwright or its browsers are missing. Attempting to install now..."
echo " This may take a few minutes as it downloads the browser binaries."
# Step 1: Install the NPM package
if ! npm install playwright; then
echo "❌ Error: Failed to install the Playwright npm package." >&2
echo " Please try running 'npm install playwright' manually in this directory." >&2
return 1
fi
# Step 2: Install the browser binaries (specifically chromium)
if ! npx playwright install chromium; then
echo "❌ Error: Failed to download Playwright's browser binaries." >&2
echo " Please try running 'npx playwright install chromium' manually." >&2
return 1
fi
echo " - ✅ Playwright and its browsers installed successfully."
PLAYWRIGHT_READY=true
return 0
}
# ----------------------------------------------------
# Checks for and installs 'gum' if not present. Sets GUM_CMD on success.
# ----------------------------------------------------
function setup_gum() {
# Return if already found
if [[ -n "$GUM_CMD" ]]; then return 0; fi
# If gum is already in the PATH, we're good to go.
if command -v gum &> /dev/null; then
GUM_CMD="gum"
return 0
fi
# Find the private directory for storing tools
local private_dir
if ! private_dir=$(_get_private_dir); then
echo "Error: Cannot find a writable directory to install gum." >&2
return 1
fi
# Find the executable inside the private directory if it's already installed
local existing_executable
existing_executable=$(find "$private_dir" -name gum -type f 2>/dev/null | head -n 1)
if [ -n "$existing_executable" ] && [ -x "$existing_executable" ] && "$existing_executable" --version &> /dev/null; then
GUM_CMD="$existing_executable"
return 0
fi
echo "Required tool 'gum' not found. Installing to '${private_dir}'..." >&2
local original_dir; original_dir=$(pwd)
cd "$private_dir" || { echo "Error: Could not enter private directory '${private_dir}'." >&2; return 1; }
local gum_dir_name="gum_${GUM_VERSION}_Linux_x86_64"
local gum_tarball="${gum_dir_name}.tar.gz"
if ! curl -sSL "https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/${gum_tarball}" -o "${gum_tarball}"; then
echo "Error: Failed to download gum." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
# Find the path of the 'gum' binary WITHIN the tarball before extracting
local gum_path_in_tar
gum_path_in_tar=$(tar -tf "${gum_tarball}" | grep '/gum$' | head -n 1)
if [ -z "$gum_path_in_tar" ]; then
echo "Error: Could not find 'gum' executable within the downloaded tarball." >&2
rm -f "${gum_tarball}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
# Now extract the tarball
if ! tar -xf "${gum_tarball}"; then
echo "Error: Failed to extract gum from tarball." >&2
rm -f "${gum_tarball}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
rm -f "${gum_tarball}"
# The full path to the executable is the private dir + the path from the tarball
local gum_executable="${private_dir}/${gum_path_in_tar}"
if [ -f "$gum_executable" ]; then
chmod +x "$gum_executable"
else
echo "Error: gum executable not found at expected path after extraction: ${gum_executable}" >&2
cd "$original_dir" > /dev/null 2>&1; return 1;
fi
# Final check
if [ -x "$gum_executable" ] && "$gum_executable" --version &> /dev/null; then
echo "'gum' installed successfully." >&2
GUM_CMD="$gum_executable"
else
echo "Error: gum installation failed. The binary at ${gum_executable} might not be executable or compatible." >&2
cd "$original_dir" > /dev/null 2>&1; return 1;
fi
cd "$original_dir" > /dev/null 2>&1; return 0;
}
# ----------------------------------------------------
# Checks for and installs 'cwebp' if not present. Sets CWEBP_CMD on success.
# ----------------------------------------------------
function setup_cwebp() {
# Return if already found
if [[ -n "$CWEBP_CMD" ]]; then return 0; fi
if command -v cwebp &> /dev/null; then CWEBP_CMD="cwebp"; return 0; fi
local private_dir
if ! private_dir=$(_get_private_dir); then
echo "Error: Cannot find a writable directory to install cwebp." >&2; return 1;
fi
local existing_executable
existing_executable=$(find "$private_dir" -name cwebp -type f 2>/dev/null | head -n 1)
if [ -n "$existing_executable" ] && [ -x "$existing_executable" ] && "$existing_executable" -version &> /dev/null; then
CWEBP_CMD="$existing_executable"; return 0;
fi
echo "Required tool 'cwebp' not found. Installing to '${private_dir}'..." >&2
local original_dir; original_dir=$(pwd)
cd "$private_dir" || { echo "Error: Could not enter private directory '${private_dir}'." >&2; return 1; }
local cwebp_dir_name="libwebp-${CWEBP_VERSION}-linux-x86-64"
local cwebp_tarball="${cwebp_dir_name}.tar.gz"
if ! curl -sSL "https://storage.googleapis.com/downloads.webmproject.org/releases/webp/${cwebp_tarball}" -o "${cwebp_tarball}"; then
echo "Error: Failed to download cwebp." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
local cwebp_path_in_tar
cwebp_path_in_tar=$(tar -tf "${cwebp_tarball}" | grep '/bin/cwebp$' | head -n 1)
if [ -z "$cwebp_path_in_tar" ]; then
echo "Error: Could not find 'cwebp' executable within the downloaded tarball." >&2
rm -f "${cwebp_tarball}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
if ! tar -xzf "${cwebp_tarball}"; then
echo "Error: Failed to extract cwebp." >&2; rm -f "${cwebp_tarball}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
rm -f "${cwebp_tarball}"
local cwebp_executable="${private_dir}/${cwebp_path_in_tar}"
if [ -f "$cwebp_executable" ]; then
chmod +x "$cwebp_executable";
else
echo "Error: cwebp executable not found at expected path: ${cwebp_executable}" >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
if [ -x "$cwebp_executable" ] && "$cwebp_executable" -version &> /dev/null; then
echo "'cwebp' installed successfully." >&2; CWEBP_CMD="$cwebp_executable";
else
echo "Error: cwebp installation failed." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
cd "$original_dir" > /dev/null 2>&1; return 0;
}
# ----------------------------------------------------
# Checks for and installs ImageMagick if not present. Sets IDENTIFY_CMD on success.
# ----------------------------------------------------
function setup_imagemagick() {
# Return if already found
if [[ -n "$IDENTIFY_CMD" ]]; then
return 0
fi
# If identify is already in the PATH, we're good to go.
if command -v identify &> /dev/null; then
IDENTIFY_CMD="identify"
return 0
fi
local private_dir
if ! private_dir=$(_get_private_dir); then
# Error message is handled by the helper function
return 1
fi
# Define the path where the extracted binary should be
local identify_executable="${private_dir}/squashfs-root/usr/bin/identify"
# Check if we have already extracted it
if [ -f "$identify_executable" ] && "$identify_executable" -version &> /dev/null; then
IDENTIFY_CMD="$identify_executable"
return 0
fi
# If not found, download and extract the AppImage
echo "Required tool 'identify' not found. Sideloading via AppImage extraction..." >&2
local imagemagick_appimage_path="${private_dir}/ImageMagick.AppImage"
# Let's use the 'gcc' version as it's a common compiler toolchain for Linux
local appimage_url="https://github.com/ImageMagick/ImageMagick/releases/download/7.1.1-47/ImageMagick-82572af-gcc-x86_64.AppImage"
echo "Downloading from ${appimage_url}..." >&2
if ! wget --quiet "$appimage_url" -O "$imagemagick_appimage_path"; then
echo "Error: Failed to download the ImageMagick AppImage." >&2
rm -f "$imagemagick_appimage_path" # Clean up partial download
return 1
fi
chmod +x "$imagemagick_appimage_path"
# --- EXTRACTION STEP ---
# This is the key change to work around the FUSE error.
echo "Extracting AppImage..." >&2
# Change into the private directory to contain the extraction
cd "$private_dir" || { echo "Error: Could not enter private directory." >&2; return 1; }
# Run the extraction. This creates a 'squashfs-root' directory.
if ! ./ImageMagick.AppImage --appimage-extract >/dev/null; then
echo "Error: Failed to extract the ImageMagick AppImage." >&2
# Clean up on failure
rm -f "ImageMagick.AppImage"
rm -rf "squashfs-root"
cd - > /dev/null
return 1
fi
# We don't need the AppImage file anymore after extraction
rm -f "ImageMagick.AppImage"
# Return to the original directory
cd - > /dev/null
# Final check
if [ -f "$identify_executable" ] && "$identify_executable" -version &> /dev/null; then
echo "'identify' binary extracted successfully to ${private_dir}/squashfs-root/" >&2
IDENTIFY_CMD="$identify_executable"
else
echo "Error: ImageMagick extraction failed. Could not find the 'identify' executable." >&2
return 1
fi
}
# ----------------------------------------------------
# Checks for and installs 'rclone' if not present. Sets RCLONE_CMD on success.
# ----------------------------------------------------
function setup_rclone() {
# Return if already found
if [[ -n "$RCLONE_CMD" ]]; then return 0; fi
if command -v rclone &> /dev/null; then RCLONE_CMD="rclone"; return 0; fi
if ! command -v unzip &>/dev/null; then echo "Error: 'unzip' command is required for rclone installation." >&2; return 1; fi
local private_dir
if ! private_dir=$(_get_private_dir); then
echo "Error: Cannot find a writable directory to install rclone." >&2; return 1;
fi
local existing_executable
existing_executable=$(find "$private_dir" -name rclone -type f 2>/dev/null | head -n 1)
if [ -n "$existing_executable" ] && [ -x "$existing_executable" ] && "$existing_executable" --version &> /dev/null; then
RCLONE_CMD="$existing_executable"; return 0;
fi
echo "Required tool 'rclone' not found. Installing to '${private_dir}'..." >&2
local original_dir; original_dir=$(pwd)
cd "$private_dir" || { echo "Error: Could not enter private directory '${private_dir}'." >&2; return 1; }
local rclone_zip="rclone-v${RCLONE_VERSION}-linux-amd64.zip"
if ! curl -sSL "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/${rclone_zip}" -o "${rclone_zip}"; then
echo "Error: Failed to download rclone." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
local rclone_path_in_zip
rclone_path_in_zip=$(unzip -l "${rclone_zip}" | grep '/rclone$' | awk '{print $4}' | head -n 1)
if [ -z "$rclone_path_in_zip" ]; then
echo "Error: Could not find 'rclone' executable within the downloaded zip." >&2
rm -f "${rclone_zip}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
if ! unzip -q -o "${rclone_zip}"; then
echo "Error: Failed to extract rclone." >&2; rm -f "${rclone_zip}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
rm -f "${rclone_zip}"
local rclone_executable="${private_dir}/${rclone_path_in_zip}"
if [ -f "$rclone_executable" ]; then
chmod +x "$rclone_executable";
else
echo "Error: rclone executable not found at expected path: ${rclone_executable}" >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
if [ -x "$rclone_executable" ] && "$rclone_executable" --version &> /dev/null; then
echo "'rclone' installed successfully." >&2; RCLONE_CMD="$rclone_executable";
else
echo "Error: rclone installation failed." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
cd "$original_dir" > /dev/null 2>&1; return 0;
}
# ----------------------------------------------------
# Checks for and installs 'restic' if not present.
# Sets RESTIC_CMD on success.
# ----------------------------------------------------
function setup_restic() {
# Return if already found
if [[ -n "$RESTIC_CMD" ]]; then return 0; fi
# If restic is already in the PATH, we're good.
if command -v restic &> /dev/null; then
RESTIC_CMD="restic"
return 0
fi
# Check for local installation in private dir
local restic_executable="$HOME/private/restic"
if [ -f "$restic_executable" ] && "$restic_executable" version &> /dev/null; then
RESTIC_CMD="$restic_executable"
return 0
fi
# If not found, download it
echo "Required tool 'restic' not found. Installing..." >&2
if ! command -v bunzip2 &>/dev/null; then echo "Error: 'bunzip2' command is required for installation." >&2; return 1; fi
mkdir -p "$HOME/private"
cd "$HOME/private" || { echo "Error: Could not enter ~/private." >&2; return 1; }
local restic_version="0.18.0"
local restic_archive="restic_${restic_version}_linux_amd64.bz2"
if ! curl -sL "https://github.com/restic/restic/releases/download/v${restic_version}/${restic_archive}" -o "${restic_archive}"; then
echo "Error: Failed to download restic." >&2
cd - > /dev/null
return 1
fi
# Decompress and extract the binary
bunzip2 -c "${restic_archive}" > restic_temp && mv restic_temp restic
rm -f "${restic_archive}"
chmod +x restic
# Final check
if [ -f "$restic_executable" ] && "$restic_executable" version &> /dev/null; then
echo "'restic' installed successfully." >&2
RESTIC_CMD="$restic_executable"
else
echo "Error: restic installation failed." >&2
cd - > /dev/null
return 1
fi
cd - > /dev/null
}
# ----------------------------------------------------
# Checks for and installs 'git' if not present. Sets GIT_CMD on success.
# ----------------------------------------------------
function setup_git() {
# Return if already found
if [[ -n "$GIT_CMD" ]]; then
return 0
fi
# If git is already in the PATH, we're good to go.
if command -v git &> /dev/null; then
GIT_CMD="git"
return 0
fi
# --- Sideloading Logic ---
echo "Required tool 'git' not found. Attempting to sideload..." >&2
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
local git_executable="${private_dir}/git/usr/bin/git"
# Check if git has already been sideloaded
if [ -f "$git_executable" ] && "$git_executable" --version &> /dev/null; then
echo "'git' found in private directory." >&2
GIT_CMD="$git_executable"
return 0
fi
# Check for wget and dpkg-deb, which are required for sideloading
if ! command -v wget &> /dev/null || ! command -v dpkg-deb &> /dev/null; then
echo "❌ Error: 'wget' and 'dpkg-deb' are required to sideload git." >&2
return 1
fi
# Determine OS distribution and version
if [ -f /etc/os-release ]; then
. /etc/os-release
else
echo "❌ Error: Cannot determine OS distribution. /etc/os-release not found." >&2
return 1
fi
if [[ "$ID" != "ubuntu" ]]; then
echo "❌ Error: Sideloading git is currently only supported on Ubuntu." >&2
return 1
fi
# Construct the download URL for the git package
# This example uses a recent stable version. You may need to update the version number periodically.
local git_version="2.47.1-0ppa1~ubuntu16.04.1"
local git_deb_url="https://launchpad.net/~git-core/+archive/ubuntu/candidate/+build/29298725/+files/git_${git_version}_amd64.deb"
local git_deb_file="${private_dir}/git_latest.deb"
echo "Downloading git from ${git_deb_url}..." >&2
if ! wget -q -O "$git_deb_file" "$git_deb_url"; then
echo "❌ Error: Failed to download git .deb package." >&2
rm -f "$git_deb_file"
return 1
fi
echo "Extracting git package..." >&2
local extract_dir="${private_dir}/git"
mkdir -p "$extract_dir"
if ! dpkg-deb -x "$git_deb_file" "$extract_dir"; then
echo "❌ Error: Failed to extract git .deb package." >&2
rm -rf "$extract_dir"
rm -f "$git_deb_file"
return 1
fi
# Clean up the downloaded .deb file
rm -f "$git_deb_file"
# Final check
if [ -f "$git_executable" ] && "$git_executable" --version &> /dev/null; then
echo "'git' sideloaded successfully." >&2
GIT_CMD="$git_executable"
return 0
else
echo "❌ Error: git sideloading failed. The git binary is not available after extraction." >&2
return 1
fi
}
# ----------------------------------------------------
# Checks for and finds the 'wp' command. Sets WP_CLI_CMD on success.
# ----------------------------------------------------
function setup_wp_cli() {
# Return if already found
if [[ -n "$WP_CLI_CMD" ]]; then return 0; fi
# 1. Check if 'wp' is already in the PATH (covers interactive shells)
if command -v wp &> /dev/null; then
WP_CLI_CMD="wp"
return 0
fi
# 2. If not in PATH, check common absolute paths for cron environments
local common_paths=(
"/usr/local/bin/wp"
"$HOME/bin/wp"
"/opt/wp-cli/wp"
)
for path in "${common_paths[@]}"; do
if [ -x "$path" ]; then
WP_CLI_CMD="$path"
return 0
fi
done
# 3. If still not found, error out
echo "❌ Error: 'wp' command not found. Please ensure WP-CLI is installed and in your PATH." >&2
return 1
}
# ----------------------------------------------------
# (Helper) Uses PHP to check if a file is a WebP image.
# ----------------------------------------------------
function _is_webp_php() {
local file_path="$1"
if [ -z "$file_path" ]; then
return 1 # Return false if no file path is provided
fi
# IMAGETYPE_WEBP has a constant value of 18 in PHP.
# We will embed the file_path directly into the PHP string.
local php_code="
\$file_to_check = '${file_path}';
if (!file_exists(\$file_to_check)) {
// Silently exit if file doesn't exist, to avoid warnings.
exit(1);
}
if (function_exists('exif_imagetype')) {
// The @ suppresses warnings for unsupported file types.
\$image_type = @exif_imagetype(\$file_to_check);
if (\$image_type === 18) { // 18 is the constant for IMAGETYPE_WEBP
exit(0); // Exit with success code (true)
}
}
exit(1); // Exit with failure code (false)
"
if ! setup_wp_cli; then
if command -v php &> /dev/null; then
php -r "\$file_path='${file_path}'; ${php_code}"
return $?
fi
return 1
fi
# Execute 'wp eval' with the self-contained code. No extra arguments are needed.
"$WP_CLI_CMD" eval "$php_code"
return $?
}
# ----------------------------------------------------
# (Primary Checker) Checks if a file is WebP, using identify or PHP fallback.
# ----------------------------------------------------
function _is_webp() {
# Determine which method to use, but only do it once.
if [[ -z "$IDENTIFY_METHOD" ]]; then
if command -v identify &> /dev/null; then
export IDENTIFY_METHOD="identify"
else
export IDENTIFY_METHOD="php"
fi
fi
# Execute the chosen method
if [[ "$IDENTIFY_METHOD" == "identify" ]]; then
# Return false if the file doesn't exist to prevent errors.
if [ ! -f "$1" ]; then return 1; fi
if [[ "$(identify -format "%m" "$1")" == "WEBP" ]]; then
return 0 # It is a WebP file
else
return 1 # It is not a WebP file
fi
else # Fallback to PHP
if _is_webp_php "$1"; then
return 0 # It is a WebP file
else
return 1 # It is not a WebP file
fi
fi
}
# ----------------------------------------------------
# Displays detailed help for a specific command.
# ----------------------------------------------------
function show_command_help() {
local cmd="$1"
# If no command is specified, show the general usage.
if [ -z "$cmd" ]; then
show_usage
return
fi
# Display help text based on the command provided.
case "$cmd" in
backup)
echo "Creates a full backup (files + DB) of a WordPress site."
echo
echo "Usage: _do backup <folder> [--quiet]"
echo
echo "Flags:"
echo " --exclude=<pattern> A file or folder pattern to exclude, relative to the backup folder."
echo " Can be used multiple times (e.g., --exclude=\"wp-content/uploads/\")."
echo " --quiet Suppress all informational output and print only the final backup URL."
;;
checkpoint)
echo "Manages versioned checkpoints of a WordPress installation's manifest."
echo
echo "Usage: _do checkpoint <subcommand> [arguments]"
echo
echo "Subcommands:"
echo " create Creates a new checkpoint of the current plugin/theme/core manifest."
echo " list Lists available checkpoints from the generated list to inspect."
echo " list-generate Generates a detailed list of all checkpoints for fast viewing."
echo " revert [<hash>] Reverts the site to the specified checkpoint hash."
echo " show <hash> Retrieves the details for a specific checkpoint hash."
echo " latest Gets the hash of the most recent checkpoint."
;;
clean)
echo "Cleans up unused WordPress components or analyzes disk usage."
echo
echo "Usage: _do clean <subcommand>"
echo
echo "Subcommands:"
echo " plugins Deletes all inactive plugins."
echo " themes Deletes all inactive themes except for the latest default WordPress theme."
echo " disk Provides an interactive disk usage analysis for the current directory."
;;
cron)
echo "Manages scheduled tasks (cron jobs) for this script."
echo
echo "Usage: _do cron <subcommand> [arguments]"
echo
echo "Subcommands:"
echo " enable Adds a job to the system crontab to run '_do cron run' every 10 minutes."
echo " list Lists all scheduled commands."
echo " run Executes any scheduled commands that are due."
echo " add \"<cmd>\" \"<time>\" \"<freq>\" Adds a new command to the schedule."
echo " delete <id> Deletes a command from the schedule."
echo
echo "Arguments for 'add':"
echo " <cmd> (Required) The _do command to run, in quotes (e.g., \"update all\")."
echo " <time> (Required) The next run time, in quotes (e.g., \"4am\", \"tomorrow 2pm\", \"+2 hours\")."
echo " <freq> (Required) The frequency, in quotes (e.g., \"1 day\", \"1 week\", \"12 hours\")."
echo
echo "Example:"
echo " _do cron add \"update all\" \"4am\" \"1 day\""
;;
db)
echo "Performs various database operations."
echo
echo "Usage: _do db <subcommand>"
echo
echo "Subcommands:"
echo " backup Performs a DB-only backup to a secure private directory."
echo " check-autoload Checks the size and top 25 largest autoloaded options in the DB."
echo " optimize Converts tables to InnoDB, reports large tables, and cleans transients."
echo " change-prefix Changes the database table prefix."
;;
disembark)
echo "Remotely installs the Disembark plugin, connects, and initiates a backup."
echo
echo "Usage: _do disembark <url> [--debug]"
echo
echo "Arguments:"
echo " <url> (Required) The full URL to the WordPress site."
echo
echo "Flags:"
echo " --debug Runs the browser automation in headed mode (not headless) for debugging."
echo
echo "You will be interactively prompted for an administrator username and password."
;;
dump)
echo "Dumps the content of files matching a pattern into a single text file."
echo
echo "Usage: _do dump \"<pattern>\" [-x <exclude_pattern_1>] [-x <exclude_pattern_2>]..."
echo
echo "Arguments:"
echo " <pattern> (Required) The path and file pattern to search for, enclosed in quotes."
echo
echo "Flags:"
echo " -x <pattern> (Optional) A file or directory pattern to exclude. Can be used multiple times."
echo " To exclude a directory, the pattern MUST end with a forward slash (e.g., 'my-dir/')."
echo
echo "Examples:"
echo " _do dump \"wp-content/plugins/my-plugin/**/*.php\""
echo " _do dump \"*\" -x \"*.log\" -x \"node_modules/\""
;;
email)
echo "Sends an email using wp_mail via WP-CLI."
echo
echo "Usage: _do email"
echo
echo "This command will interactively prompt for the recipient, subject, and content."
;;
find)
echo "Finds files or WordPress components based on specific criteria."
echo
echo "Usage: _do find <subcommand> [arguments]"
echo
echo "Subcommands:"
echo " recent-files [days] Finds files modified within the last <days>. Defaults to 1 day."
echo " slow-plugins [path] Identifies plugins slowing down WP-CLI. Optionally check a specific page path (e.g., \"/contact\")."
echo " hidden-plugins Detects active plugins that may be hidden from the standard list."
echo " malware Scans for malware and verifies core/plugin file integrity."
echo " php-tags [dir] Finds outdated PHP short tags ('<?'). Defaults to 'wp-content/'."
;;
https)
echo "Applies HTTPS to all site URLs."
echo
echo "Usage: _do https"
echo
echo "This command will interactively ask whether to use 'www.' or not in the final URL"
echo "and then perform a search-and-replace across the entire database."
;;
convert-to-webp)
echo "Finds and converts large images (JPG, PNG) to WebP format."
echo
echo "Usage: _do convert-to-webp [folder] [--all]"
echo
echo "Arguments:"
echo " [folder] (Optional) The folder to convert. Defaults to 'wp-content/uploads'."
echo
echo "Flags:"
echo " --all Convert all images, regardless of size. Defaults to images > 1MB."
;;
install)
echo "Installs helper or premium plugins."
echo
echo "Usage: _do install <subcommand> [--flags]"
echo
echo "Subcommands:"
echo " kinsta-mu Installs the Kinsta MU plugin. Use --force to install outside a Kinsta environment."
echo " helper Installs the CaptainCore Helper plugin."
echo " events-calendar-pro Installs The Events Calendar and its Pro version after prompting for a license."
;;
launch)
echo "Launches a site: updates URL from dev to live, enables search engines, and clears cache."
echo
echo "Usage: _do launch [--domain=<domain>]"
echo
echo "Flags:"
echo " --domain=<domain> (Optional) The new domain name. If omitted, you will be prompted interactively."
;;
migrate)
echo "Migrates a site from a backup snapshot."
echo
echo "Usage: _do migrate --url=<backup-url> [--update-urls]"
echo
echo " --update-urls Update urls to destination WordPress site. Default will keep source urls."
;;
monitor)
echo "Monitors server access logs or errors in real-time."
echo
echo "Usage: _do monitor <subcommand> [--flags]"
echo
echo "Subcommands:"
echo " traffic Analyzes and monitors top hits from access logs."
echo " errors Monitors logs for HTTP 500 and PHP fatal errors."
echo " access.log Provides a real-time stream of the access log."
echo " error.log Provides a real-time stream of the error log."
echo
echo "Flags for 'traffic':"
echo " --top=<number> The number of top IP/Status combinations to show. Default is 25."
echo " --now Start processing from the end of the log file instead of the beginning."
;;
php-tags)
echo "Finds outdated or invalid PHP opening tags in PHP files."
echo
echo "Usage: _do php-tags [directory]"
echo
echo "Arguments:"
echo " [directory] (Optional) The directory to search in. Defaults to 'wp-content/'."
;;
reset)
echo "Resets WordPress components or permissions."
echo
echo "Usage: _do reset <subcommand> [arguments]"
echo
echo "Subcommands:"
echo " wp Resets the WordPress installation to a default state."
echo " permissions Resets file and folder permissions to defaults (755 for dirs, 644 for files)."
;;
suspend)
echo "Activates or deactivates a suspend message shown to visitors."
echo
echo "Usage: _do suspend <subcommand> [flags]"
echo
echo "Subcommands:"
echo " activate Activates the suspend message. Requires --name and --link flags."
echo " deactivate Deactivates the suspend message."
echo
echo "Flags for 'activate':"
echo " --name=<business-name> (Required) The name of the business to display."
echo " --link=<business-link> (Required) The contact link for the business."
echo " --wp-content=<path> (Optional) Path to wp-content directory. Defaults to 'wp-content'."
echo
echo "Flags for 'deactivate':"
echo " --wp-content=<path> (Optional) Path to wp-content directory. Defaults to 'wp-content'."
;;
update)
echo "Handles WordPress core, theme, and plugin updates."
echo
echo "Usage: _do update <subcommand>"
echo
echo "Subcommands:"
echo " all Creates a 'before' checkpoint, runs all updates, creates an"
echo " 'after' checkpoint, and logs the changes."
echo " list Shows a list of past updates to inspect from the generated list."
echo " list-generate Generates a detailed list of all updates for fast viewing."
;;
upgrade)
echo "Upgrades the _do script to the latest version."
echo
echo "Usage: _do upgrade"
;;
vault)
echo "Manages secure, full site snapshots in a remote Restic repository."
echo
echo "Usage: _do vault <subcommand> [arguments]"
echo
echo "Subcommands:"
echo " create Creates a new snapshot of the current site."
echo " snapshots [--output] Lists available snapshots. Use --output for a non-interactive list."
echo " snapshot-info <id> Displays detailed information for a single snapshot."
echo " delete <id> Deletes a specific snapshot by its ID."
echo " mount Mounts the entire repository to a local folder for Browse."
echo " info Displays statistics about the repository."
echo " prune Removes unnecessary data from the repository."
echo
echo "Authentication:"
echo " This command requires B2 and Restic credentials, provided either by"
echo " environment variables (B2_ACCOUNT_ID, B2_ACCOUNT_KEY, RESTIC_PASSWORD,"
echo " B2_BUCKET, B2_PATH) or by piping a 5-line secrets file via stdin."
echo
echo "Example (stdin):"
echo " cat secrets.txt | _do vault snapshots"
;;
version)
echo "Displays the current version of the _do script."
echo
echo "Usage: _do version"
;;
wpcli)
echo "Checks for and identifies sources of WP-CLI warnings."
echo
echo "Usage: _do wpcli <subcommand>"
echo
echo "Subcommands:"
echo " check Runs a check to find themes or plugins causing WP-CLI warnings."
;;
zip)
echo "Creates a zip archive of a specified folder."
echo
echo "Usage: _do zip \"<folder>\""
echo
echo "Arguments:"
echo " <folder> (Required) The folder to be archived."
echo
echo "If the folder is a WordPress installation, a public URL to the zip file will be provided."
echo "Otherwise, the local file path and size will be displayed."
;;
*)
echo "Error: Unknown command '$cmd' for help." >&2
echo ""
show_usage
exit 1
;;
esac
}
# ----------------------------------------------------
# Displays the main help and usage information.
# ----------------------------------------------------
function show_usage() {
echo "CaptainCore _do (v$CAPTAINCORE_DO_VERSION)"
echo "--------------------------"
echo "A collection of useful command-line utilities for managing WordPress sites."
echo ""
echo "Usage:"
echo " _do <command> [arguments] [--flags]"
echo ""
echo "Available Commands:"
echo " backup Creates a full backup (files + DB) of a WordPress site."
echo " checkpoint Manages versioned checkpoints of the site's manifest."
echo " clean Removes unused items like inactive themes or analyzes disk usage."
echo " convert-to-webp Finds and converts large images (JPG, PNG) to WebP format."
echo " cron Manages cron jobs and schedules tasks to run at specific times."
echo " db Performs various database operations (backup, check-autoload, optimize)."
echo " disembark Remotely connects to a site to generate a backup."
echo " dump Dumps the content of files matching a pattern into a single text file."
echo " email Sends an email using wp_mail via WP-CLI."
echo " find Finds files, slow plugins, hidden plugins or outdated PHP tags."
echo " https Applies HTTPS to all site URLs, with an option for www/non-www."
echo " install Installs helper plugins or premium plugins."
echo " launch Launches a site to a new domain, using a flag or interactively."
echo " migrate Migrates a site from a backup URL or local file."
echo " monitor Monitors server logs or errors in real-time."
echo " reset Resets WordPress components or permissions."
echo " suspend Activates or deactivates a suspend message shown to visitors."
echo " update Runs WordPress updates and logs the changes."
echo " upgrade Upgrades this script to the latest version."
echo " vault Manages secure snapshots in a remote Restic repository."
echo " version Displays the current version of the _do script."
echo " wpcli Checks for and identifies sources of WP-CLI warnings."
echo " zip Creates a zip archive of a specified folder."
echo ""
echo "Run '_do help <command>' for more information on a specific command."
}
# --- Main Entry Point and Argument Parser ---
function main() {
# If no arguments are provided, show usage and exit.
if [ $# -eq 0 ]; then
show_usage
exit 0
fi
# --- Help Flag Handling ---
# Detect 'help <command>' pattern
if [[ "$1" == "help" ]]; then
show_command_help "$2"
exit 0
fi
# Detect '<command> --help' pattern
for arg in "$@"; do
if [[ "$arg" == "--help" || "$arg" == "-h" ]]; then
# The first non-flag argument is the command we need help for.
local help_for_cmd=""
for inner_arg in "$@"; do
# Find the first argument that doesn't start with a hyphen.
if [[ ! "$inner_arg" =~ ^- ]]; then
help_for_cmd="$inner_arg"
break
fi
done
show_command_help "$help_for_cmd"
exit 0
fi
done
# --- Centralized Argument Parser ---
# This loop separates flags from commands.
local url_flag=""
local top_flag=""
local name_flag=""
local link_flag=""
local wp_content_flag=""
local update_urls_flag=""
local now_flag=""
local admin_user_flag=""
local path_flag=""
local all_files_flag=""
local force_flag=""
local format_flag=""
local domain_flag=""
local output_flag=""
local exclude_patterns=()
local backup_exclude_patterns=()
local positional_args=()
local quiet_flag=""
local debug_flag=""
while [[ $# -gt 0 ]]; do
case $1 in
--url=*)
url_flag="${1#*=}"
shift
;;
--domain=*)
domain_flag="${1#*=}"
shift
;;
--top=*)
top_flag="${1#*=}"
shift
;;
--now)
now_flag=true
shift
;;
--name=*)
name_flag="${1#*=}"
shift
;;
--link=*)
link_flag="${1#*=}"
shift
;;
--wp-content=*)
wp_content_flag="${1#*=}"
shift
;;
--update-urls)
update_urls_flag=true
shift
;;
--admin_user=*)
admin_user_flag="${1#*=}"
shift
;;
--all)
all_files_flag=true
shift
;;
--force)
force_flag=true
shift
;;
--format=*)
format_flag="${1#*=}"
shift
;;
--output)
output_flag=true
shift
;;
--path=*)
path_flag="${1#*=}"
shift
;;
-x) # Exclude flag
if [[ -n "$2" ]]; then
exclude_patterns+=("$2")
shift 2 # past flag and value
else
echo "Error: -x flag requires an argument." >&2
exit 1
fi
;;
--exclude=*)
backup_exclude_patterns+=("${1#*=}")
shift
;;
--quiet)
quiet_flag=true
shift
;;
--debug)
debug_flag=true
shift
;;
-*)
# This will catch unknown flags like --foo
echo "Error: Unknown flag: $1" >&2
show_usage
exit 1
;;
*)
# It's a command or a positional argument
positional_args+=("$1")
shift # past argument
;;
esac
done
# --- Global Path Handling ---
# If a path is provided, change to that directory first.
# This allows commands to be run from anywhere for a specific site.
if [[ -n "$path_flag" ]]; then
if [ -d "$path_flag" ]; then
cd "$path_flag" || { echo "❌ Error: Could not change directory to '$path_flag'." >&2; exit 1; }
else
echo "❌ Error: Provided path '$path_flag' does not exist." >&2
exit 1
fi
fi
# The first positional argument is the main command.
local command="${positional_args[0]}"
# --- Command Router ---
# This routes to the correct function based on the parsed command.
case "$command" in
backup)
full_backup "${positional_args[1]}" "$quiet_flag" "$format_flag" "${backup_exclude_patterns[@]}"
;;
checkpoint)
local subcommand="${positional_args[1]}"
case "$subcommand" in
create)
checkpoint_create
;;
list)
checkpoint_list
;;
list-generate)
checkpoint_list_generate
;;
revert)
local hash="${positional_args[2]}"
checkpoint_revert "$hash"
;;
show)
local hash="${positional_args[2]}"
checkpoint_show "$hash"
;;
latest)
checkpoint_latest
;;
*)
show_command_help "checkpoint"
exit 0
;;
esac
;;
clean)
local arg1="${positional_args[1]}"
case "$arg1" in
plugins)
clean_plugins
;;
themes)
clean_themes
;;
disk)
clean_disk
;;
*)
show_command_help "clean"
exit 0
;;
esac
;;
cron)
local subcommand="${positional_args[1]}"
case "$subcommand" in
enable)
cron_enable
;;
list)
cron_list
;;
run)
cron_run
;;
add)
cron_add "${positional_args[2]}" "${positional_args[3]}" "${positional_args[4]}"
;;
delete)
cron_delete "${positional_args[2]}"
;;
*)
show_command_help "cron"
exit 0
;;
esac
;;
convert-to-webp)
# Pass the optional folder (positional_args[1]) and the --all flag
convert_to_webp "${positional_args[1]}" "$all_files_flag"
;;
db)
local arg1="${positional_args[1]}"
case "$arg1" in
backup)
db_backup # Originally from backup-db command
;;
check-autoload)
db_check_autoload # Originally from db-check-autoload command
;;
optimize)
db_optimize # Originally from db-optimize command
;;
change-prefix)
db_change_prefix
;;
*)
show_command_help "db"
exit 0
;;
esac
;;
disembark)
run_disembark "${positional_args[1]}" "$debug_flag"
;;
dump)
# There should be exactly 2 positional args total: 'dump' and the pattern.
if [ ${#positional_args[@]} -ne 2 ]; then
echo -e "Error: Incorrect number of arguments for 'dump'. It's likely your pattern was expanded by the shell." >&2
echo "Please wrap the input pattern in double quotes." >&2
echo -e "\n Usage: _do dump \"<pattern>\" [-x <exclude1>...]" >&2
return 1
fi
run_dump "${positional_args[1]}" "${exclude_patterns[@]}"
;;
email)
run_email
;;
find)
local subcommand="${positional_args[1]}"
case "$subcommand" in
recent-files)
find_recent_files "${positional_args[2]}"
;;
slow-plugins)
find_slow_plugins "${positional_args[2]}"
;;
hidden-plugins)
find_hidden_plugins
;;
malware)
find_malware
;;
php-tags)
find_outdated_php_tags "${positional_args[2]}"
;;
*)
show_command_help "find"
exit 0
;;
esac
;;
https)
run_https
;;
install)
local subcommand="${positional_args[1]}"
case "$subcommand" in
kinsta-mu)
install_kinsta_mu "$force_flag"
;;
helper)
install_helper
;;
events-calendar-pro)
install_events_calendar_pro
;;
*)
show_command_help "install"
exit 0
;;
esac
;;
launch)
run_launch "$domain_flag"
;;
migrate)
if [[ -z "$url_flag" ]]; then
echo "Error: The 'migrate' command requires the --url=<...> flag." >&2
show_command_help "migrate"
exit 1
fi
migrate_site "$url_flag" "$update_urls_flag"
;;
monitor)
local arg1="${positional_args[1]}"
case "$arg1" in
traffic)
monitor_traffic "$top_flag" "$now_flag"
;;
errors)
monitor_errors
;;
access.log)
monitor_access_log
;;
error.log)
monitor_error_log
;;
*)
show_command_help "monitor"
exit 0
;;
esac
;;
php-tags)
find_outdated_php_tags "${positional_args[1]}"
;;
reset)
local subcommand="${positional_args[1]}"
case "$subcommand" in
wp)
reset_wp "$admin_user_flag"
;;
permissions)
reset_permissions
;;
*)
show_command_help "reset"
exit 0
;;
esac
;;
suspend)
local arg1="${positional_args[1]}"
case "$arg1" in
activate)
suspend_activate "$name_flag" "$link_flag" "$wp_content_flag"
;;
deactivate)
suspend_deactivate "$wp_content_flag"
;;
*)
show_command_help "suspend"
exit 0
;;
esac
;;
update)
local subcommand="${positional_args[1]}"
case "$subcommand" in
all)
run_update_all
;;
list)
update_list
;;
list-generate)
update_list_generate
;;
*)
show_command_help "update"
exit 1
;;
esac
;;
upgrade)
run_upgrade
;;
vault)
local subcommand="${positional_args[1]}" # Default to snapshots
case "$subcommand" in
create)
vault_create
;;
delete)
local snapshot_id_to_delete="${positional_args[2]}"
vault_delete "$snapshot_id_to_delete"
;;
snapshot-info)
local snapshot_id_to_show="${positional_args[2]}"
vault_snapshot_info "$snapshot_id_to_show"
;;
snapshots)
vault_snapshots "$output_flag"
;;
mount)
vault_mount
;;
info)
vault_info
;;
prune)
vault_prune
;;
*)
show_command_help "vault" >&2
exit 0
;;
esac
;;
version|--version|-v)
show_version
;;
wpcli)
local subcommand="${positional_args[1]}"
case "$subcommand" in
check)
wpcli_check
;;
*)
show_command_help "wpcli"
exit 0
;;
esac
;;
zip)
run_zip "${positional_args[1]}"
;;
*)
echo "Error: Unknown command '$command'." >&2
show_usage
exit 1
;;
esac
}
# Pass all script arguments to the main function.
# --- Sourced Command Functions ---
# The following functions are sourced from the 'commands/' directory.
# ----------------------------------------------------
# Creates a full backup of a WordPress site (files + database).
# ----------------------------------------------------
function full_backup() {
# --- 1. Argument Parsing ---
local target_folder="$1"
local quiet_flag="$2"
local format_flag="$3"
shift 3 # Move past the first two arguments
local exclude_patterns=("$@") # The rest of the arguments are the exclude patterns
# --- 2. Validation ---
if [ -z "$target_folder" ]
then echo "Error: Please provide a folder path." >&2; echo "Usage: _do backup <folder>" >&2; return 1;
fi
if ! command -v realpath &> /dev/null; then echo "Error: 'realpath' command not found. Please install it." >&2; return 1; fi
if [ ! -d "$target_folder" ]; then echo "Error: Folder '$target_folder' not found." >&2; return 1; fi
# --- 3. Setup Paths and Filenames ---
# Resolve the absolute path to handle cases like "."
local full_target_path; full_target_path=$(realpath "$target_folder")
local parent_dir; parent_dir=$(dirname "$full_target_path")
local site_dir_name; site_dir_name=$(basename "$full_target_path")
local today; today=$(date +"%Y-%m-%d")
local random; random=$(openssl rand -hex 4 | head -c 7)
local backup_filename="${today}_${random}.zip"
local original_dir; original_dir=$(pwd)
# Change to the parent directory for consistent relative paths in the zip
cd "$parent_dir" || return 1
# --- 4. Database Export ---
if ! setup_wp_cli; then echo "Error: wp-cli is not installed." >&2; cd "$original_dir"; return 1; fi
local home_url; home_url=$("$WP_CLI_CMD" option get home --path="$site_dir_name" --skip-plugins --skip-themes)
local name; name=$("$WP_CLI_CMD" option get blogname --path="$site_dir_name" --skip-plugins --skip-themes)
local database_file="db_export.sql"
if [[ "$quiet_flag" != "true" ]]; then
echo "Exporting database for '$name'...";
fi
if ! "$WP_CLI_CMD" db export "$site_dir_name/$database_file" --path="$site_dir_name" --add-drop-table --default-character-set=utf8mb4 > /dev/null; then
echo "Error: Database export failed." >&2
cd "$original_dir"
return 1
fi
if [[ "$quiet_flag" != "true" ]]; then
echo "Creating zip archive...";
if [ ${#exclude_patterns[@]} -gt 0 ]; then
echo "Excluding the following patterns:"
for pattern in "${exclude_patterns[@]}"; do
echo " - $pattern"
done
fi
fi
# --- 5. Zip Archive Creation ---
local zip_exclude_args=()
zip_exclude_args+=(-x "$site_dir_name/wp-content/updraft/*") # Add default exclude
for pattern in "${exclude_patterns[@]}"; do
zip_exclude_args+=(-x "$site_dir_name/$pattern*") # Add user-defined excludes
done
# Create the zip in the parent directory, zipping the site directory
if ! zip -r "$backup_filename" "$site_dir_name" "${zip_exclude_args[@]}" > /dev/null; then
echo "Error: Failed to zip files." >&2
rm -f "$site_dir_name/$database_file"
cd "$original_dir"
return 1
fi
# Add wp-config.php if it exists in the site directory
if [ -f "$site_dir_name/wp-config.php" ]; then
zip "$backup_filename" "$site_dir_name/wp-config.php" > /dev/null
fi
# --- 6. Cleanup and Final Report ---
local size
size=$(ls -lh "$backup_filename" | awk '{print $5}')
rm -f "$site_dir_name/$database_file"
mv "$backup_filename" "$site_dir_name/"
local final_backup_location="$site_dir_name/$backup_filename"
cd "$original_dir"
local final_backup_location="$full_target_path/$backup_filename"
local final_url="${home_url}/${backup_filename}"
if [[ "$format_flag" == "filename" ]]; then
echo "$backup_filename"
return 0
fi
if [[ "$quiet_flag" == "true" ]]; then
echo "$final_url"
return 0
fi
echo "-----------------------------------------------------"
echo "✅ Full site backup complete!"
echo " Name: $name"
echo " Location: $final_backup_location"
echo " Size: $size"
echo " URL: $final_url"
echo "-----------------------------------------------------"
echo "When done, remember to remove the backup file."
echo "rm -f \"$final_backup_location\""
}
# ----------------------------------------------------
# Checkpoint Commands
# Manages versioned checkpoints of the site's manifest.
# ----------------------------------------------------
# --- Checkpoint Base Directory ---
CHECKPOINT_BASE_DIR=""
CHECKPOINT_REPO_DIR=""
CHECKPOINT_LIST_FILE=""
# ----------------------------------------------------
# (Helper) Reverts a specific item's files to a given checkpoint hash.
# ----------------------------------------------------
function _revert_item_to_hash() {
local item_type="$1" # "plugin" or "theme"
local item_name="$2" # e.g., "akismet"
local target_hash="$3" # The git hash to revert to
local wp_content_dir="$4" # The live wp-content directory
local repo_dir="$5" # The path to the checkpoint git repo
local current_hash="$6" # The hash we are reverting FROM (for cleanup)
# Define paths
local item_path_in_repo="${item_type}s/${item_name}"
local restored_source_path="${repo_dir}/${item_path_in_repo}/"
local live_item_path="${wp_content_dir}/${item_type}s/${item_name}"
echo "Reverting '$item_name' files to state from checkpoint ${target_hash:0:7}..."
# Use git to restore the files *within the checkpoint repo*
"$GIT_CMD" -C "$repo_dir" checkout "$target_hash" -- "$item_path_in_repo" &>/dev/null
if [ $? -ne 0 ]; then
echo "❌ Error: git checkout failed. Could not restore files from checkpoint." >&2
# Clean up by checking out the original state before we messed with it
"$GIT_CMD" -C "$repo_dir" checkout "$current_hash" -- "$item_path_in_repo" &>/dev/null
return 1
fi
# Sync the restored files from the repo back to the live site
echo "Syncing restored files to the live site..."
# If the path no longer exists in the reverted repo state, it should be deleted from the live site.
if [ ! -e "${repo_dir}/${item_path_in_repo}" ]; then
echo " - Item did not exist in target checkpoint. Removing from live site..."
if [ -e "$live_item_path" ]; then
rm -rf "$live_item_path"
echo " ✅ Removed '$live_item_path'."
else
echo " - Already absent from live site. No action needed."
fi
else
# The item existed. Sync it to the live site. This handles both updates and re-additions.
echo " - Item existed in target checkpoint. Syncing files..."
rsync -a --delete "$restored_source_path" "$live_item_path/"
echo " ✅ Synced files to '$live_item_path/'."
fi
# IMPORTANT: Revert the repo back to the original hash so it remains consistent.
# This resets the state of the repo, leaving only the live files changed.
"$GIT_CMD" -C "$repo_dir" checkout "$current_hash" -- "$item_path_in_repo" &>/dev/null
echo "✅ Revert complete for '$item_name'."
echo "💡 Note: This action reverts files only. Database or activation status changes are not affected."
}
# ----------------------------------------------------
# Ensures checkpoint directories and lists exist.
# ----------------------------------------------------
function _ensure_checkpoint_setup() {
# Exit if already initialized
if [[ -n "$CHECKPOINT_BASE_DIR" ]]; then return 0; fi
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
CHECKPOINT_BASE_DIR="${private_dir}/checkpoints"
CHECKPOINT_REPO_DIR="$CHECKPOINT_BASE_DIR/repo"
CHECKPOINT_LIST_FILE="$CHECKPOINT_BASE_DIR/list.json"
mkdir -p "$CHECKPOINT_REPO_DIR"
if [ ! -f "$CHECKPOINT_LIST_FILE" ]; then
echo "[]" > "$CHECKPOINT_LIST_FILE"
fi
}
# ----------------------------------------------------
# Generates a JSON manifest of the current WP state and saves it to a file.
# ----------------------------------------------------
function _generate_manifest() {
local output_file="$1"
if [ -z "$output_file" ]; then
echo "Error: No output file provided to _generate_manifest." >&2
return 1
fi
local core_version; core_version=$("$WP_CLI_CMD" core version --skip-plugins --skip-themes)
local plugins; plugins=$("$WP_CLI_CMD" plugin list --fields=name,title,status,version,auto_update --format=json --skip-plugins --skip-themes)
local themes; themes=$("$WP_CLI_CMD" theme list --fields=name,title,status,version,auto_update --format=json --skip-plugins --skip-themes)
# Manually create JSON to avoid extra dependencies
cat <<EOF > "$output_file"
{
"core": "$core_version",
"plugins": $plugins,
"themes": $themes
}
EOF
}
# ----------------------------------------------------
# Creates a new checkpoint.
# ----------------------------------------------------
function checkpoint_create() {
if ! setup_git; then return 1; fi
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! command -v rsync &>/dev/null; then echo "❌ Error: rsync command not found." >&2; return 1; fi
_ensure_checkpoint_setup
echo "🚀 Creating new checkpoint..."
# Get wp-content path
local wp_content_dir
wp_content_dir=$("$WP_CLI_CMD" eval "echo rtrim(WP_CONTENT_DIR, '/');" --skip-plugins --skip-themes 2>/dev/null)
if [ -z "$wp_content_dir" ] || [ ! -d "$wp_content_dir" ]; then
echo "❌ Error: Could not determine wp-content directory." >&2
return 1
fi
echo " - Found wp-content at: $wp_content_dir"
# Sync files
echo " - Syncing themes, plugins, and mu-plugins..."
mkdir -p "$CHECKPOINT_REPO_DIR/themes" "$CHECKPOINT_REPO_DIR/plugins" "$CHECKPOINT_REPO_DIR/mu-plugins"
# Use rsync to copy directories. The trailing slashes are important.
rsync -a --delete --exclude='*.zip' --exclude='logs/' --exclude='.git/' "$wp_content_dir/themes/" "$CHECKPOINT_REPO_DIR/themes/"
rsync -a --delete --exclude='*.zip' --exclude='logs/' --exclude='.git/' "$wp_content_dir/plugins/" "$CHECKPOINT_REPO_DIR/plugins/"
if [ -d "$wp_content_dir/mu-plugins" ]; then
rsync -a --delete --exclude='*.zip' --exclude='logs/' --exclude='.git/' "$wp_content_dir/mu-plugins/" "$CHECKPOINT_REPO_DIR/mu-plugins/"
fi
local manifest_file="$CHECKPOINT_REPO_DIR/manifest.json"
echo " - Generating manifest..."
if ! _generate_manifest "$manifest_file"; then
echo "❌ Error: Failed to generate manifest file." >&2
return 1
fi
# Initialize git repo if it doesn't exist
if [ ! -d "$CHECKPOINT_REPO_DIR/.git" ]; then
echo " - Initializing checkpoint repository..."
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" init -b main > /dev/null
fi
echo " - Committing changes to repository..."
# Add all changes (manifest + files)
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" add .
# Check if there are changes to commit
if "$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --staged --quiet; then
echo "✅ No changes detected. Checkpoint is up-to-date."
local latest_hash; latest_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" rev-parse HEAD 2>/dev/null)
if [ -n "$latest_hash" ]; then
echo " Latest Hash: $latest_hash"
fi
return 0
fi
# Set a default author identity for the commit to prevent errors on remote systems
# where git might not be configured. This is a local config for this repo only.
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" config user.email "script@captaincore.io"
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" config user.name "_do Script"
local timestamp; timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" commit -m "Checkpoint $timestamp" > /dev/null
if [ $? -ne 0 ]; then
echo "❌ Error: Failed to commit checkpoint changes." >&2
return 1
fi
local commit_hash; commit_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" rev-parse HEAD)
if [ -z "$commit_hash" ]; then
echo "❌ Error: Could not retrieve commit hash after creating checkpoint." >&2
return 1
fi
local checkpoint_file="$CHECKPOINT_BASE_DIR/$commit_hash.json"
echo " - Saving checkpoint details..."
printf '{\n "hash": "%s",\n "timestamp": "%s"\n}\n' "$commit_hash" "$timestamp" > "$checkpoint_file"
# Safely update the JSON list file using a PHP script
local php_code_template='
<?php
$list_file = "%s";
$hash = "%s";
$timestamp = "%s";
$list = file_exists($list_file) ? json_decode(file_get_contents($list_file), true) : [];
if (!is_array($list)) { $list = []; }
$new_entry = ["hash" => $hash, "timestamp" => $timestamp];
array_unshift($list, $new_entry);
echo json_encode($list, JSON_PRETTY_PRINT);
'
local php_script; php_script=$(printf "$php_code_template" "$CHECKPOINT_LIST_FILE" "$commit_hash" "$timestamp")
local temp_list_file; temp_list_file=$(mktemp)
if echo "$php_script" | "$WP_CLI_CMD" eval-file - > "$temp_list_file"; then
mv "$temp_list_file" "$CHECKPOINT_LIST_FILE"
else
echo "❌ Error: Failed to update checkpoint list." >&2
rm "$temp_list_file"
fi
echo "✅ Checkpoint created successfully."
echo " Hash: $commit_hash"
# Automatically regenerate the detailed checkpoint list
echo " - Regenerating detailed checkpoint list..."
checkpoint_list_generate > /dev/null
}
# ----------------------------------------------------
# Generates a detailed list of checkpoints for faster access.
# ----------------------------------------------------
function checkpoint_list_generate() {
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
_ensure_checkpoint_setup
# Read the potentially simple list created by `checkpoint create`
local php_script_read_list='
<?php
$list_file = "%s";
if (!file_exists($list_file)) { return; }
$list = json_decode(file_get_contents($list_file), true);
if (!is_array($list) || empty($list)) { return; }
foreach($list as $item) {
if (isset($item["timestamp"]) && isset($item["hash"])) {
echo $item["timestamp"] . "|" . $item["hash"] . "\n";
}
}
'
local php_script; php_script=$(printf "$php_script_read_list" "$CHECKPOINT_LIST_FILE")
local checkpoint_entries; checkpoint_entries=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [ -z "$checkpoint_entries" ]; then
echo " No checkpoints found to generate a list from."
return 0
fi
echo "🔎 Generating detailed checkpoint list... (This may take a moment)"
local detailed_items=()
while IFS='|' read -r timestamp hash; do
hash=$(echo "$hash" | tr -d '[:space:]')
if [ -z "$hash" ]; then continue; fi
local parent_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" log -n 1 --pretty=format:%P "$hash" 2>/dev/null)
local manifest_current; manifest_current=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" show "$hash:manifest.json" 2>/dev/null)
if [ -z "$manifest_current" ]; then continue; fi
local php_get_counts='
<?php
$manifest_json = <<<'EOT'
%s
EOT;
$data = json_decode($manifest_json, true);
$theme_count = isset($data["themes"]) && is_array($data["themes"]) ? count($data["themes"]) : 0;
$plugin_count = isset($data["plugins"]) && is_array($data["plugins"]) ? count($data["plugins"]) : 0;
echo "$theme_count Themes, $plugin_count Plugins";
'
local counts_script; counts_script=$(printf "$php_get_counts" "$manifest_current")
local counts_str; counts_str=$(echo "$counts_script" | "$WP_CLI_CMD" eval-file -)
local diff_stats="Initial checkpoint"
if [ -n "$parent_hash" ]; then
diff_stats=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --shortstat "$parent_hash" "$hash" -- 'plugins/' 'themes/' 'mu-plugins/' | sed 's/^[ \t]*//')
if [ -z "$diff_stats" ]; then diff_stats="No file changes"; fi
fi
local formatted_timestamp
if [[ "$(uname)" == "Darwin" ]]; then
formatted_timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" -u "$timestamp" "+%a, %b %d, %Y, %-I:%M %p")
else
formatted_timestamp=$(date -d "$timestamp" "+%a, %b %d, %Y, %-I:%M %p")
fi
# Create a JSON object for this item
local json_item
json_item=$(printf '{"hash": "%s", "timestamp": "%s", "formatted_timestamp": "%s", "counts_str": "%s", "diff_stats": "%s"}' \
"$hash" "$timestamp" "$formatted_timestamp" "$counts_str" "$diff_stats")
detailed_items+=("$json_item")
done <<< "$checkpoint_entries"
# Write the detailed list back to the file
local full_json="["
full_json+=$(IFS=,; echo "${detailed_items[*]}")
full_json+="]"
# Use PHP to pretty-print the JSON to the file
local php_write_script='
<?php
$list_file = "%s";
$json_data = <<<'EOT'
%s
EOT;
$data = json_decode($json_data, true);
file_put_contents($list_file, json_encode($data, JSON_PRETTY_PRINT));
'
local write_script; write_script=$(printf "$php_write_script" "$CHECKPOINT_LIST_FILE" "$full_json")
if echo "$write_script" | "$WP_CLI_CMD" eval-file -; then
echo "✅ Detailed checkpoint list saved to $CHECKPOINT_LIST_FILE"
else
echo "❌ Error: Failed to write detailed list."
fi
}
# ----------------------------------------------------
# (Helper) Lets the user select a checkpoint hash from the list.
# ----------------------------------------------------
function _select_checkpoint_hash() {
_ensure_checkpoint_setup
if ! setup_wp_cli; then return 1; fi
if [ ! -s "$CHECKPOINT_LIST_FILE" ]; then
echo " No checkpoints found. Run '_do checkpoint create' to make one." >&2
exit 1
fi
# Use PHP to read the detailed list and check if it's in the new format
local php_script_read_list='
<?php
$list_file = "%s";
$list = json_decode(file_get_contents($list_file), true);
if (!is_array($list) || empty($list)) {
echo "EMPTY";
return;
}
// Check if the first item has the detailed format.
if (!isset($list[0]["formatted_timestamp"])) {
echo "NEEDS_GENERATE";
return;
}
foreach($list as $item) {
if (isset($item["formatted_timestamp"]) && isset($item["hash"]) && isset($item["counts_str"]) && isset($item["diff_stats"])) {
// Output format: formatted_timestamp|hash|counts_str|diff_stats
echo $item["formatted_timestamp"] . "|" . $item["hash"] . "|" . $item["counts_str"] . "|" . $item["diff_stats"] . "\n";
}
}
'
local php_script; php_script=$(printf "$php_script_read_list" "$CHECKPOINT_LIST_FILE")
local checkpoint_entries; checkpoint_entries=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [[ "$checkpoint_entries" == "EMPTY" ]]; then
echo " No checkpoints available to select." >&2
exit 1 # Use exit 1 to guarantee a non-zero exit code from the subshell
elif [[ "$checkpoint_entries" == "NEEDS_GENERATE" ]]; then
echo "⚠️ The checkpoint list needs to be generated for faster display." >&2
echo "Please run: _do checkpoint list-generate" >&2
exit 1 # Use exit 1
fi
local display_items=()
local data_items=()
# Get terminal width for dynamic padding
local term_width; term_width=$(tput cols)
local hash_col_width=9 # "xxxxxxx |"
local counts_col_width=20 # "xx Themes, xx Plugins |"
# Calculate available width for the timestamp column
local timestamp_col_width=$((term_width - hash_col_width - counts_col_width - 5)) # 5 for buffers
# Set a reasonable minimum and maximum
if [ "$timestamp_col_width" -lt 20 ]; then timestamp_col_width=20; fi
if [ "$timestamp_col_width" -gt 30 ]; then timestamp_col_width=30; fi
while IFS='|' read -r formatted_timestamp hash counts_str diff_stats; do
hash=$(echo "$hash" | tr -d '[:space:]')
if [ -z "$hash" ]; then continue; fi
# Use the new dynamic width for the timestamp
local display_string
display_string=$(printf "%-${timestamp_col_width}s | %s | %-18s | %s" \
"$formatted_timestamp" "${hash:0:7}" "$counts_str" "$diff_stats")
display_items+=("$display_string")
data_items+=("$hash")
done <<< "$checkpoint_entries"
if [ ${#display_items[@]} -eq 0 ]; then
echo "❌ No valid checkpoints to display." >&2
exit 1
fi
local prompt_text="${1:-Select a checkpoint to inspect}"
local selected_display
selected_display=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --height=20 --prompt="👇 $prompt_text" --indicator="→" --placeholder="")
if [ -z "$selected_display" ]; then
echo "" # Return empty for cancellation
return 0
fi
local selected_index=-1
for i in "${!display_items[@]}"; do
if [[ "${display_items[$i]}" == "$selected_display" ]]; then
selected_index=$i
break
fi
done
if [ "$selected_index" -ne -1 ]; then
echo "${data_items[$selected_index]}"
return 0
else
echo "❌ Error: Could not find selected checkpoint." >&2
exit 1
fi
}
# ----------------------------------------------------
# Lists all checkpoints from the pre-generated list and allows selection.
# ----------------------------------------------------
function checkpoint_list() {
if ! setup_gum || ! setup_git; then return 1; fi
local selected_hash
selected_hash=$(_select_checkpoint_hash "Select a checkpoint to inspect")
local exit_code=$?
if [ $exit_code -ne 0 ]; then
return 1
fi
if [ -z "$selected_hash" ]; then
echo "No checkpoint selected."
return 0
fi
checkpoint_show "$selected_hash"
}
# ----------------------------------------------------
# Reverts all files to a specific checkpoint hash.
# ----------------------------------------------------
function checkpoint_revert() {
local target_hash="$1"
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
# If no hash is provided, let the user pick one from a detailed list.
if [ -z "$target_hash" ]; then
target_hash=$(_select_checkpoint_hash "Select a checkpoint to revert to")
if [ -z "$target_hash" ]; then
echo "Revert cancelled."
return 0
fi
# Check the exit code of the helper
if [ $? -ne 0 ]; then
return 1 # Error was already printed by the helper
fi
fi
_ensure_checkpoint_setup
# Validate the hash to ensure it exists in the repo before proceeding
if ! "$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" cat-file -e "${target_hash}^{commit}" &>/dev/null; then
echo "❌ Error: Checkpoint hash '$target_hash' not found." >&2
return 1
fi
# Final confirmation before the revert
echo "🚨 You are about to revert ALL themes, plugins, and mu-plugins to the state from checkpoint ${target_hash:0:7}."
echo "This will overwrite any changes made since that checkpoint was created."
"$GUM_CMD" confirm "Are you sure you want to proceed?" || { echo "Revert cancelled."; return 0; }
# Get wp-content path for rsync destination
local wp_content_dir
wp_content_dir=$("$WP_CLI_CMD" eval "echo rtrim(WP_CONTENT_DIR, '/');" --skip-plugins --skip-themes 2>/dev/null)
if [ -z "$wp_content_dir" ] || [ ! -d "$wp_content_dir" ]; then
echo "❌ Error: Could not determine wp-content directory." >&2
return 1
fi
local current_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" rev-parse HEAD)
# Revert all three directories within the git repo
echo "Reverting all tracked files to checkpoint ${target_hash:0:7}..."
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" checkout "$target_hash" -- 'plugins/' 'themes/' 'mu-plugins/' &>/dev/null
# Sync the reverted files from the repo to the live site directories
echo "Syncing restored files to the live site..."
rsync -a --delete "$CHECKPOINT_REPO_DIR/plugins/" "$wp_content_dir/plugins/"
rsync -a --delete "$CHECKPOINT_REPO_DIR/themes/" "$wp_content_dir/themes/"
rsync -a --delete "$CHECKPOINT_REPO_DIR/mu-plugins/" "$wp_content_dir/mu-plugins/"
# IMPORTANT: Reset the repo's state back to the original `HEAD`
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" checkout "$current_hash" -- 'plugins/' 'themes/' 'mu-plugins/' &>/dev/null
echo "✅ Full file revert to checkpoint ${target_hash:0:7} is complete."
echo "💡 Note: This action reverts files only. Database changes, plugin/theme activation status, and WordPress core version are not affected."
}
# ----------------------------------------------------
# Shows the diff between two checkpoints or one checkpoint and its parent.
# ----------------------------------------------------
function checkpoint_show() {
local hash_after="$1"
local hash_before="$2"
if [ -z "$hash_after" ]; then
echo "❌ Error: No hash provided." >&2
show_command_help "checkpoint"
return 1
fi
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
_ensure_checkpoint_setup
# If 'before' hash is not provided, find the parent of the 'after' hash.
if [ -z "$hash_before" ]; then
hash_before=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" log -n 1 --pretty=format:%P "$hash_after" 2>/dev/null)
fi
local manifest_after
manifest_after=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" show "$hash_after:manifest.json" 2>/dev/null)
if [ -z "$manifest_after" ]; then
echo "❌ Error: Could not find manifest for 'after' hash '$hash_after'." >&2
return 1
fi
local manifest_before="{}"
if [ -n "$hash_before" ]; then
manifest_before=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" show "$hash_before:manifest.json" 2>/dev/null)
if [ -z "$manifest_before" ]; then
echo "⚠️ Warning: Could not find manifest for 'before' hash '$hash_before'. Comparing against an empty state." >&2
manifest_before="{}"
fi
fi
# Get a list of themes and plugins that have actual file changes.
local changed_files_list
if [ -z "$hash_before" ]; then
# This is the initial commit, compare against the empty tree.
changed_files_list=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff-tree --no-commit-id --name-only -r "$hash_after" -- 'plugins/' 'themes/' 'mu-plugins/')
else
changed_files_list=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --name-only "$hash_before" "$hash_after" -- 'plugins/' 'themes/' 'mu-plugins/')
fi
local items_with_file_changes=()
while IFS= read -r file_path; do
if [[ -n "$file_path" ]]; then
local item_name
item_name=$(echo "$file_path" | cut -d'/' -f2)
items_with_file_changes+=("$item_name")
fi
done <<< "$changed_files_list"
local changed_items_str
changed_items_str=$(printf "%s\n" "${items_with_file_changes[@]}" | sort -u | tr '\n' ',' | sed 's/,$//')
# --- 1. Generate list of ALL items from PHP ---
export MANIFEST_AFTER_JSON="$manifest_after"
export MANIFEST_BEFORE_JSON="$manifest_before"
export CHANGED_ITEMS_STR="$changed_items_str"
local php_script
read -r -d '' php_script <<'PHP'
<?php
// This PHP script compares two manifest files and outputs pipe-delimited data for shell processing.
$manifest_after_json = getenv('MANIFEST_AFTER_JSON');
$manifest_before_json = getenv('MANIFEST_BEFORE_JSON');
$changed_items_str = getenv('CHANGED_ITEMS_STR');
$after_data = json_decode($manifest_after_json, true);
$before_data = json_decode($manifest_before_json, true);
$items_with_file_changes = !empty($changed_items_str) ? explode(',', $changed_items_str) : [];
function process_item_diff($item_type, $after_items, $before_items, $items_with_file_changes) {
$after_map = [];
if (is_array($after_items)) { foreach ($after_items as $item) { if(isset($item["name"])) $after_map[$item["name"]] = $item; } }
$before_map = [];
if (is_array($before_items)) { foreach ($before_items as $item) { if(isset($item["name"])) $before_map[$item["name"]] = $item; } }
$all_names = array_unique(array_merge(array_keys($after_map), array_keys($before_map)));
sort($all_names);
$output_lines = [];
foreach ($all_names as $name) {
if(empty($name)) continue;
$has_changed = false;
$change_parts = [];
$after_item = $after_map[$name] ?? null;
$before_item = $before_map[$name] ?? null;
if ($after_item && $before_item) {
// Check for status changes first for correct ordering
if (($after_item["status"] ?? null) !== ($before_item["status"] ?? null)) {
$change_parts[] = "status " . ($before_item["status"] ?? 'N/A') . " -> " . ($after_item["status"] ?? 'N/A');
$has_changed = true;
}
if (($after_item["version"] ?? null) !== ($before_item["version"] ?? null)) {
$change_parts[] = "version " . ($before_item["version"] ?? 'N/A') . " -> " . ($after_item["version"] ?? 'N/A');
$has_changed = true;
}
if (in_array($name, $items_with_file_changes, true)) {
$change_parts[] = "files changed";
$has_changed = true;
}
} elseif ($after_item) {
$change_parts[] = "installed";
if(isset($after_item["version"])) $change_parts[] = "v" . $after_item["version"];
$has_changed = true;
} else {
$change_parts[] = "deleted";
$has_changed = true;
}
$item_for_details = $after_item ?: $before_item;
$title = $item_for_details["title"] ?? $name;
if ($has_changed) {
$details_string = implode(", ", array_unique($change_parts));
} else {
$version = $item_for_details["version"] ?? 'N/A';
$status = $item_for_details["status"] ?? 'N/A';
// Format with status first, then version
$details_string = "$status, v$version";
}
$output_lines[] = [
'has_changed' => $has_changed,
'type' => $item_type,
'slug' => $name,
'title' => $title,
'details' => $details_string,
];
}
// Sort to show changed items first
usort($output_lines, function ($a, $b) {
if ($a['has_changed'] !== $b['has_changed']) {
return $b['has_changed'] <=> $a['has_changed'];
}
return strcmp($a['slug'], $b['slug']);
});
// Output as pipe-delimited data
foreach ($output_lines as $line) {
echo implode("|", [
$line['has_changed'] ? 'true' : 'false',
$line['type'],
$line['slug'],
$line['title'],
$line['details']
]) . "\n";
}
}
process_item_diff('Theme', $after_data['themes'] ?? [], $before_data['themes'] ?? [], $items_with_file_changes);
process_item_diff('Plugin', $after_data['plugins'] ?? [], $before_data['plugins'] ?? [], $items_with_file_changes);
PHP
local php_output
php_output=$(echo "$php_script" | "$WP_CLI_CMD" eval-file - 2>/dev/null)
# Unset the environment variables
unset MANIFEST_AFTER_JSON
unset MANIFEST_BEFORE_JSON
unset CHANGED_ITEMS_STR
if [ -z "$php_output" ]; then
echo "✅ No manifest changes found between checkpoints ${hash_before:0:7} and ${hash_after:0:7}."
return 0
fi
local display_items=()
local data_items=()
# Use fixed-width columns for consistent alignment
local slug_width=30
local title_width=42
# Read the pipe-delimited output from PHP and format it for display
while IFS='|' read -r has_changed item_type slug title details; do
local icon
if [[ "$has_changed" == "true" ]];
then
icon="+"
else
icon=$(printf '\xE2\xA0\x80\xE2\xA0\x80')
fi
# Use "%-2s" to create a 2-character wide column for the icon.
# This ensures consistent padding for both the "+" and " " cases.
# Note the space between "%-2s" and "%-8s" is preserved.
local display_line
display_line=$(printf "%-2s%-8s %-*.*s %-*.*s %s" "$icon" "$item_type" "$slug_width" "$slug_width" "$slug" "$title_width" "$title_width" "$title" "$details")
display_items+=("$display_line")
data_items+=("$item_type|$slug")
done <<< "$php_output"
# Start a loop to allow returning to the item selection.
while true; do
# --- 2. Interactive Item Selection ---
local selected_display_text
selected_display_text=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --prompt="? Select item to inspect (checkpoints ${hash_before:0:7} -> ${hash_after:0:7}). Press Esc to exit." --height=20 --indicator="→" --placeholder="")
if [ -z "$selected_display_text" ]; then
# User pressed Esc, so break the loop and exit the function.
break
fi
local selected_index=-1
for i in "${!display_items[@]}"; do
if [[ "${display_items[$i]}" == "$selected_display_text" ]]; then
selected_index=$i
break
fi
done
if [ "$selected_index" -eq -1 ]; then
# Should not happen, but as a safeguard
continue
fi
local item_data="${data_items[$selected_index]}"
local item_type; item_type=$(echo "$item_data" | cut -d'|' -f1)
local item_name; item_name=$(echo "$item_data" | cut -d'|' -f2)
# --- 3. Get wp-content Path ---
local wp_content_dir
wp_content_dir=$("$WP_CLI_CMD" eval "echo rtrim(WP_CONTENT_DIR, '/');" --skip-plugins --skip-themes 2>/dev/null)
if [ -z "$wp_content_dir" ] || [ ! -d "$wp_content_dir" ]; then
echo "❌ Error: Could not determine wp-content directory." >&2
return 1
fi
# --- 4. Interactive Action Selection ---
local choices=("Show File Changes" "Revert Files to 'After' State (${hash_after:0:7})")
if [ -n "$hash_before" ]; then
choices+=("Revert Files to 'Before' State (${hash_before:0:7})")
fi
choices+=("Back to item list")
local action; action=$("$GUM_CMD" choose "${choices[@]}")
# --- 5. Execute Action ---
case "$action" in
"Show File Changes")
local item_path_in_repo
case "$item_type" in
"Theme")
item_path_in_repo="themes/${item_name}"
;;
"Plugin")
item_path_in_repo="plugins/${item_name}"
;;
esac
# Get the list of files that have changed for the selected item.
local changed_files
if [ -z "$hash_before" ]; then
changed_files=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff-tree --no-commit-id --name-only -r "$hash_after" -- "$item_path_in_repo")
else
changed_files=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --name-only "$hash_before" "$hash_after" -- "$item_path_in_repo")
fi
if [ -z "$changed_files" ]; then
"$GUM_CMD" spin --spinner dot --title "No file changes found for '$item_name'." -- sleep 3
else
echo "Showing file changes for '$item_name' between ${hash_before:0:7} and ${hash_after:0:7}."
# Loop to allow viewing multiple diffs
while true; do
clear
local selected_file
selected_file=$(echo "$changed_files" | "$GUM_CMD" filter --prompt="? Select a file to view its diff (Press Esc to exit)" --height=20 --indicator="→" --placeholder="")
if [ -z "$selected_file" ]; then
break
fi
# Show the diff for the selected file, piped to `less`.
if [ -z "$hash_before" ]; then
# For the initial commit, just show the file content as it was added.
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" --no-pager show --color=always "$hash_after" -- "$selected_file" | less -RX
else
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" --no-pager diff --color=always "$hash_before" "$hash_after" -- "$selected_file" | less -RX
fi
done
fi
;;
"Revert Files to 'After' State ("*)
_revert_item_to_hash "$item_type" "$item_name" "$hash_after" "$wp_content_dir" "$CHECKPOINT_REPO_DIR" "$hash_after"
;;
"Revert Files to 'Before' State ("*)
if [ -z "$hash_before" ]; then
echo "❌ Cannot revert: 'Before' state does not exist (likely the first checkpoint)." >&2
return 1
fi
_revert_item_to_hash "$item_type" "$item_name" "$hash_before" "$wp_content_dir" "$CHECKPOINT_REPO_DIR" "$hash_after"
;;
"Back to item list"|*)
# Do nothing, the loop will continue to the next iteration.
continue
;;
esac
done
}
# ----------------------------------------------------
# Gets the latest checkpoint hash.
# ----------------------------------------------------
function checkpoint_latest() {
_ensure_checkpoint_setup
if ! setup_wp_cli; then return 1; fi
if [ ! -s "$CHECKPOINT_LIST_FILE" ]; then
echo " No checkpoints found."
return
fi
local php_code_template='
<?php
$list_file = "%s";
if (!file_exists($list_file)) { return; }
$list = json_decode(file_get_contents($list_file));
if (!empty($list) && isset($list[0]->hash)) {
echo $list[0]->hash;
}
'
local php_script; php_script=$(printf "$php_code_template" "$CHECKPOINT_LIST_FILE")
local latest_hash
latest_hash=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [ -z "$latest_hash" ]; then
echo " No checkpoints found."
else
echo "$latest_hash"
fi
}
# ----------------------------------------------------
# Cleans up inactive themes.
# ----------------------------------------------------
function clean_themes() {
# --- Pre-flight checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
echo "🔎 Finding the latest default WordPress theme to preserve..."
latest_default_theme=$("$WP_CLI_CMD" theme search twenty --field=slug --per-page=1 --quiet --skip-plugins --skip-themes)
if [ $? -ne 0 ] || [ -z "$latest_default_theme" ]; then
echo "❌ Error: Could not determine the latest default theme. Aborting." >&2
return 1
fi
echo "✅ The latest default theme is '$latest_default_theme'. This will be preserved."
inactive_themes=($("$WP_CLI_CMD" theme list --status=inactive --field=name --skip-plugins --skip-themes))
if [ ${#inactive_themes[@]} -eq 0 ]; then
echo "👍 No inactive themes found to process. All done!"
return 0
fi
echo "🚀 Processing ${#inactive_themes[@]} inactive themes..."
for theme in "${inactive_themes[@]}"; do
# Check if the current inactive theme is the one we want to keep
if [[ "$theme" == "$latest_default_theme" ]]; then
echo "⚪️ Keeping inactive default theme: $theme"
else
echo "❌ Deleting inactive theme: $theme"
"$WP_CLI_CMD" theme delete "$theme"
fi
done
echo "✨ Cleanup complete."
}
# ----------------------------------------------------
# Deletes inactive plugins.
# On multisite, only deletes plugins not active on any site.
# ----------------------------------------------------
function clean_plugins() {
# --- Pre-flight checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! setup_gum; then return 1; fi
echo "🚀 Cleaning inactive plugins..."
# --- Multisite vs. Single Site Logic ---
if "$WP_CLI_CMD" core is-installed --network --quiet; then
echo " Multisite installation detected. Finding plugins that are not active on any site..."
# Get all installed plugins (slugs)
local all_installed_plugins
all_installed_plugins=$("$WP_CLI_CMD" plugin list --field=name)
# Get all network-activated plugins
local network_active_plugins
network_active_plugins=$("$WP_CLI_CMD" plugin list --network --status=active --field=name)
# Get all plugins active on individual sites
local site_active_plugins
site_active_plugins=$("$WP_CLI_CMD" site list --field=url --format=ids | xargs -I % "$WP_CLI_CMD" plugin list --url=% --status=active --field=name)
# Combine all active plugins (network + site-specific) and get a unique, sorted list
local all_active_plugins
all_active_plugins=$(echo -e "${network_active_plugins}\n${site_active_plugins}" | sort -u | grep -v '^$')
# Find plugins that are in the installed list but not in the combined active list
local plugins_to_delete
plugins_to_delete=$(comm -23 <(echo "$all_installed_plugins" | sort) <(echo "$all_active_plugins" | sort))
else
echo " Single site installation detected. Finding inactive plugins..."
# On a single site, 'inactive' is sufficient
local plugins_to_delete
plugins_to_delete=$("$WP_CLI_CMD" plugin list --status=inactive --field=name)
fi
if [ -z "$plugins_to_delete" ]; then
echo "✅ No inactive plugins found to delete."
return 0
fi
local plugins_to_delete_count
plugins_to_delete_count=$(echo "$plugins_to_delete" | wc -l | xargs)
echo "🔎 Found ${plugins_to_delete_count} plugin(s) to delete:"
echo "$plugins_to_delete"
echo
if ! "$GUM_CMD" confirm "Proceed with deletion?"; then
echo "Operation cancelled by user."
return 0
fi
while IFS= read -r plugin; do
if [ -n "$plugin" ]; then
echo " - Deleting '$plugin'..."
"$WP_CLI_CMD" plugin delete "$plugin"
fi
done <<< "$plugins_to_delete"
echo "✨ Plugin cleanup complete."
}
# ----------------------------------------------------
# Analyzes disk usage using rclone.
# ----------------------------------------------------
function clean_disk() {
echo "🚀 Launching interactive disk usage analysis..."
if ! setup_rclone; then
echo "Aborting analysis: rclone setup failed." >&2
return 1
fi
"$RCLONE_CMD" ncdu .
}
# ----------------------------------------------------
# Finds large images and converts them to the
# WebP format.
# ----------------------------------------------------
function convert_to_webp() {
# The target directory is the first positional argument.
# The --all flag is passed as the second argument from main.
local target_dir_arg="$1"
local all_files_flag="$2"
echo "🚀 Starting WebP Conversion Process 🚀"
# --- Pre-flight Checks ---
if ! setup_cwebp; then
echo "Aborting conversion: cwebp setup failed." >&2
return 1
fi
# --- Initialize and Report Identify Method ---
# Prime the _is_webp function to determine the method and export the choice.
_is_webp ""
# Now that IDENTIFY_METHOD is set, report the choice to the user once.
if [[ "$IDENTIFY_METHOD" == "identify" ]]; then
echo "Using 'identify' command for image type checking."
else
echo "Warning: 'identify' command not found. Falling back to PHP check."
fi
# --- Determine target directory ---
local target_dir="wp-content/uploads"
if [ -n "$target_dir_arg" ]; then
target_dir="$target_dir_arg"
echo "Targeting custom directory: $target_dir"
else
echo "Targeting default directory: $target_dir"
fi
if [ ! -d "$target_dir" ]; then
echo "❌ Error: Cannot find '$target_dir' directory." >&2
return 1
fi
# --- Size and File Discovery ---
local before_size
before_size="$(du -sh "$target_dir" | awk '{print $1}')"
echo "Current directory size: $before_size"
local size_limit_mb=1
local message="larger than ${size_limit_mb}MB"
local find_args=("$target_dir" -type f)
if [[ "$all_files_flag" == "true" ]]; then
message="of all sizes"
else
find_args+=(-size "+${size_limit_mb}M")
fi
find_args+=(\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \))
local files
files=$(find "${find_args[@]}")
if [[ -z "$files" ]]; then
echo "✅ No images ${message} found to convert."
return 0
fi
local count
count=$(echo "$files" | wc -l | xargs)
echo "Found $count image(s) ${message} to process using up to 5 concurrent threads..."
echo ""
# --- Helper function for processing a single image ---
# This function will be run in the background for each image.
_process_single_image() {
local file="$1"
local current_num="$2"
local total_num="$3"
# The _is_webp function checks if the file is already in WebP format.
if _is_webp "$file"; then
echo "⚪️ Skipping ${current_num}/${total_num} (already WebP): $file"
return
fi
local temp_file="${file}.temp.webp"
local before_file_size
before_file_size=$(ls -lh "$file" | awk '{print $5}')
# The actual conversion command, output is suppressed for a cleaner log.
"$CWEBP_CMD" -q 80 "$file" -o "$temp_file" > /dev/null 2>&1
# Check if the conversion was successful and the new file has content.
if [ -s "$temp_file" ]; then
mv "$temp_file" "$file"
local after_file_size
after_file_size=$(ls -lh "$file" | awk '{print $5}')
echo "✅ Converted ${current_num}/${total_num} ($before_file_size -> $after_file_size): $file"
else
# Cleanup failed temporary file and report the failure.
rm -f "$temp_file"
echo "❌ Failed ${current_num}/${total_num}: $file"
fi
}
# Export the helper function and its dependencies so they are available to the subshells.
export -f _process_single_image
export -f _is_webp
export -f _is_webp_php
export -f setup_wp_cli
# --- Concurrent Processing Loop ---
local max_jobs=5
local job_count=0
local processed_count=0
# Use process substitution to avoid creating a subshell for the while loop.
# This ensures the main script's 'wait' command can see all background jobs.
while IFS= read -r file; do
processed_count=$((processed_count + 1))
# Run the processing function in the background.
_process_single_image "$file" "$processed_count" "$count" &
# On Linux, use 'wait -n' for a sliding window of jobs.
# On macOS, bash doesn't support 'wait -n', so we skip this
# and let the final 'wait' handle all jobs at once.
if [[ "$(uname)" != "Darwin" ]]; then
job_count=$((job_count + 1))
if (( job_count >= max_jobs )); then
wait -n
job_count=$((job_count - 1))
fi
fi
done < <(echo "$files")
# Wait for all remaining background jobs to complete before proceeding.
wait
# --- Final Summary ---
echo ""
local after_size
after_size="$(du -sh "$target_dir" | awk '{print $1}')"
echo "✅ Bulk conversion complete!"
echo "-----------------------------------------------------"
echo " Directory size reduced from $before_size to $after_size."
echo "-----------------------------------------------------"
}
# ----------------------------------------------------
# Cron Commands
# Manages scheduled tasks for the _do script.
# ----------------------------------------------------
# ----------------------------------------------------
# (Helper) PHP script to manage cron events.
# ----------------------------------------------------
function _get_cron_manager_php_script() {
read -r -d '' php_script <<'PHP'
<?php
$argv = WP_CLI::get_runner()->arguments;
array_shift( $argv );
// This script is a self-contained manager for cron events stored in a WP option.
// It is designed to be called with specific actions and arguments.
// Prevent direct execution.
if (empty($argv) || !isset($argv[1])) {
return;
}
$action = $argv[1] ?? null;
// The main function for this script is to get the option, unserialize it,
// perform an action, then serialize and save the result.
function get_events() {
// get_option will return the value of the option, already unserialized.
// The second argument is the default value if the option does not exist.
$events = get_option("captaincore_do_cron", []);
return is_array($events) ? $events : [];
}
function save_events($events) {
// update_option will create the option if it does not exist.
// The third argument 'no' sets autoload to false.
update_option( "captaincore_do_cron", $events, 'no');
}
// --- Action Router ---
if ($action === 'list_all') {
$events = get_events();
if (empty($events)) {
return;
}
// Sort events by the next_run timestamp to show the soonest first.
usort($events, function($a, $b) {
return ($a['next_run'] ?? 0) <=> ($b['next_run'] ?? 0);
});
// MODIFICATION: Get the WordPress timezone once before the loop.
$wp_timezone = wp_timezone();
foreach ($events as $event) {
// MODIFICATION: Convert the stored UTC timestamp to the WP timezone for display.
$next_run_formatted = 'N/A';
if (isset($event['next_run'])) {
// Create a DateTime object from the UTC timestamp
$next_run_dt = new DateTime("@" . $event['next_run']);
// Set the object's timezone to the WordPress configured timezone
$next_run_dt->setTimezone($wp_timezone);
// Format for output
$next_run_formatted = $next_run_dt->format('Y-m-d H:i:s T');
}
// Output in CSV format for gum table
echo implode(',', [
$event['id'] ?? 'N/A',
'"' . ($event['command'] ?? 'N/A') . '"', // Quote command in case it has spaces
$next_run_formatted,
$event['frequency'] ?? 'N/A'
]) . "\n";
}
}
elseif ($action === 'list_due') {
$now = time();
$due_events = [];
foreach (get_events() as $event) {
if (isset($event['next_run']) && $event['next_run'] <= $now) {
$due_events[] = $event;
}
}
echo json_encode($due_events);
}
elseif ($action === 'add') {
$id = uniqid('event_');
$command = $argv[2] ?? null;
$next_run_str = $argv[3] ?? null;
$frequency = $argv[4] ?? null;
if (!$command || !$next_run_str || !$frequency) {
error_log('Error: Missing arguments for add action.');
return;
}
// --- Frequency Translation ---
$freq_lower = strtolower($frequency);
if ($freq_lower === 'weekly') {
$frequency = '1 week';
} elseif ($freq_lower === 'daily') {
$frequency = '1 day';
} elseif ($freq_lower === 'monthly') {
$frequency = '1 month';
} elseif ($freq_lower === 'hourly') {
$frequency = '1 hour';
}
// --- End Translation ---
try {
// Use the WordPress configured timezone for parsing the date string.
$wp_timezone = wp_timezone();
$next_run_dt = new DateTime($next_run_str, $wp_timezone);
$next_run_timestamp = $next_run_dt->getTimestamp();
} catch (Exception $e) {
error_log('Error: Invalid date/time string for next_run: ' . $e->getMessage());
return;
}
$events = get_events();
$events[] = [
'id' => $id,
'command' => $command,
'next_run' => $next_run_timestamp,
'frequency' => $frequency,
];
save_events($events);
echo "✅ Event '$id' added. Input '{$next_run_str}' interpreted using WordPress timezone ({$wp_timezone->getName()}). Next run: " . date('Y-m-d H:i:s T', $next_run_timestamp) . "\n";
}
elseif ($action === 'delete') {
$id_to_delete = $argv[2] ?? null;
if (!$id_to_delete) {
error_log('Error: No ID provided for delete action.');
return;
}
$events = get_events();
$updated_events = [];
$found = false;
foreach ($events as $event) {
if (isset($event['id']) && $event['id'] === $id_to_delete) {
$found = true;
} else {
$updated_events[] = $event;
}
}
if ($found) {
save_events($updated_events);
echo "✅ Event '$id_to_delete' deleted successfully.\n";
} else {
echo "❌ Error: Event with ID '$id_to_delete' not found.\n";
}
}
elseif ($action === 'update_next_run') {
$id = $argv[2] ?? null;
if (!$id) {
error_log('Error: No ID provided to update_next_run.');
return;
}
$events = get_events();
$found = false;
foreach ($events as &$event) {
if (isset($event['id']) && $event['id'] === $id) {
try {
$last_run_ts = $event['next_run'] ??
time();
$next_run_dt = new DateTime("@{$last_run_ts}", new DateTimeZone('UTC'));
do {
$next_run_dt->modify('+ ' . $event['frequency']);
} while ($next_run_dt->getTimestamp() <= time());
$event['next_run'] = $next_run_dt->getTimestamp();
$found = true;
break;
} catch (Exception $e) {
error_log('Error: Invalid frequency string "' . ($event['frequency'] ?? '') . '": ' . $e->getMessage());
return;
}
}
}
if ($found) {
save_events($events);
}
}
PHP
echo "$php_script"
}
# ----------------------------------------------------
# Configures the global cron runner by installing the latest script.
# ----------------------------------------------------
function cron_enable() {
echo "Attempting to configure cron runner..."
if ! setup_wp_cli || ! "$WP_CLI_CMD" core is-installed --quiet; then
echo "❌ Error: This command must be run from within a WordPress directory." >&2
return 1
fi
if ! command -v realpath &> /dev/null || ! command -v md5sum &> /dev/null; then
echo "❌ Error: 'realpath' and 'md5sum' commands are required." >&2
return 1
fi
# Determine the absolute path of the WordPress installation
local wp_path
wp_path=$(realpath ".")
if [[ ! -f "$wp_path/wp-load.php" ]]; then
echo "❌ Error: Could not confirm WordPress root at '$wp_path'." >&2
return 1
fi
local private_dir
if ! private_dir=$(_get_private_dir); then return 1; fi
local script_path="$private_dir/_do.sh"
echo " Downloading the latest version of the _do script..."
if ! command -v curl &> /dev/null; then
echo "❌ Error: 'curl' is required to download the script." >&2; return 1;
fi
if ! curl -sL "https://captaincore.io/do" -o "$script_path"; then
echo "❌ Error: Failed to download the _do script." >&2; return 1;
fi
chmod +x "$script_path"
echo "✅ Script installed/updated at: $script_path"
# Make the marker unique to the path to allow multiple cron jobs
local path_hash
path_hash=$(echo "$wp_path" | md5sum | cut -d' ' -f1)
local cron_marker="#_DO_CRON_RUNNER_$path_hash"
local cron_command="bash \"$script_path\" cron run --path=\"$wp_path\""
local cron_job="*/10 * * * * $cron_command $cron_marker"
# Atomically update the crontab
local current_crontab
current_crontab=$(crontab -l 2>/dev/null | grep -v "$cron_marker")
(echo "$current_crontab"; echo "$cron_job") | crontab -
if [ $? -eq 0 ]; then
local site_url
site_url=$("$WP_CLI_CMD" option get home --skip-plugins --skip-themes 2>/dev/null)
echo "✅ Cron runner enabled for site: $site_url ($wp_path)"
else
echo "❌ Error: Could not modify crontab. Please check your permissions." >&2
return 1
fi
echo "Current crontab:"
crontab -l
}
# ----------------------------------------------------
# Runs the cron process, executing any due events.
# ----------------------------------------------------
function cron_run() {
# If a path is provided, change to that directory first.
if [[ -n "$path_flag" ]]; then
if [ -d "$path_flag" ]; then
cd "$path_flag" || { echo "[$(date)] Cron Error: Could not change directory to '$path_flag'." >> /tmp/_do_cron.log; return 1; }
else
echo "[$(date)] Cron Error: Provided path '$path_flag' does not exist." >> /tmp/_do_cron.log;
return 1
fi
fi
# Call the setup function to ensure the wp command path is known.
if ! setup_wp_cli; then
echo "[$(date)] Cron Error: WP-CLI setup failed." >> /tmp/_do_cron.log
return 1
fi
# Check if this is a WordPress installation using the full command path.
if ! "$WP_CLI_CMD" core is-installed --quiet; then
return 1
fi
local php_script;
php_script=$(_get_cron_manager_php_script)
local due_events_json;
# Use the full command path for all wp-cli calls.
due_events_json=$(echo "$php_script" | "$WP_CLI_CMD" eval-file - 'list_due' 2>&1)
if [ $? -ne 0 ];
then
echo "[$(date)] Cron run failed: $due_events_json" >> /tmp/_do_cron.log
return 1
fi
if [ -z "$due_events_json" ] || [[ "$due_events_json" == "[]" ]];
then
return 0
fi
local php_parser='
$json = file_get_contents("php://stdin");
$events = json_decode($json, true);
if (is_array($events)) {
foreach($events as $event) {
echo $event["id"] . "|" . $event["command"] . "\n";
}
}
'
local due_events_list;
due_events_list=$(echo "$due_events_json" | php -r "$php_parser")
if [ -z "$due_events_list" ];
then
return 0
fi
echo "Found due events, processing..."
while IFS='|' read -r id command; do
if [ -z "$id" ] || [ -z "$command" ]; then
continue
fi
echo "-> Running event '$id': _do $command"
local script_path
script_path=$(realpath "$0")
bash "$script_path" $command
echo "-> Updating next run time for event '$id'"
# Use the full command path here as well.
echo "$php_script" | "$WP_CLI_CMD" eval-file - 'update_next_run' "$id"
done <<< "$due_events_list"
echo "Cron run complete."
}
# ----------------------------------------------------
# Adds a new command to the cron schedule.
# ----------------------------------------------------
function cron_add() {
local command="$1"
local next_run="$2"
local frequency="$3"
if [ -z "$command" ] || [ -z "$next_run" ] || [ -z "$frequency" ]; then
echo "❌ Error: Missing arguments." >&2
show_command_help "cron"
return 1
fi
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: Not in a WordPress installation." >&2; return 1; fi
echo "Adding new cron event..."
local php_script; php_script=$(_get_cron_manager_php_script)
# Capture both stdout and stderr to a variable
local output; output=$(echo "$php_script" | "$WP_CLI_CMD" eval-file - "add" "$command" "$next_run" "$frequency" 2>&1)
# Check the exit code of the wp-cli command
if [ $? -ne 0 ]; then
echo "❌ Error: The wp-cli command failed to execute."
echo " Output:"
# Indent the output for readability
echo "$output" | sed 's/^/ /'
else
# Print the success message from the PHP script
echo "$output"
fi
}
# ----------------------------------------------------
# Deletes a scheduled cron event by its ID.
# ----------------------------------------------------
function cron_delete() {
local event_id="$1"
if [ -z "$event_id" ]; then
echo "❌ Error: No event ID provided." >&2
show_command_help "cron"
return 1
fi
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: Not in a WordPress installation." >&2; return 1; fi
echo "Attempting to delete event '$event_id'..."
local php_script
php_script=$(_get_cron_manager_php_script)
# Capture and display output from the PHP script
local output
output=$(echo "$php_script" | "$WP_CLI_CMD" eval-file - "delete" "$event_id" 2>&1)
# The PHP script now prints success or error, so just display it.
echo "$output"
}
# ----------------------------------------------------
# Lists all scheduled cron events in a table.
# ----------------------------------------------------
function cron_list() {
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: Not in a WordPress installation." >&2; return 1; fi
if ! setup_gum; then return 1; fi
echo "🔎 Fetching scheduled events..."
local php_script; php_script=$(_get_cron_manager_php_script)
# Capture stdout and stderr
local events_csv; events_csv=$(echo "$php_script" | "$WP_CLI_CMD" eval-file - 'list_all' 2>&1)
# Check exit code
if [ $? -ne 0 ]; then
echo "❌ Error: The wp-cli command failed while listing events."
echo " Output:"
echo "$events_csv" | sed 's/^/ /'
return 1
fi
if [ -z "$events_csv" ]; then
echo " No scheduled cron events found."
return 0
fi
local table_header="ID,Command,Next Run,Frequency"
# Prepend the header and pipe to gum table for a formatted view
(echo "$table_header"; echo "$events_csv") | "$GUM_CMD" table --print --separator ","
}
# ----------------------------------------------------
# Performs a WordPress database-only backup to a secure, private directory.
# ----------------------------------------------------
function db_backup() {
echo "Starting database-only backup..."
local home_directory; home_directory=$(pwd);
local private_directory
if ! private_directory=$(_get_private_dir); then
return 1
fi
if ! setup_wp_cli; then echo "Error: wp-cli is not installed." >&2; return 1; fi
local database_name; database_name=$("$WP_CLI_CMD" config get DB_NAME --skip-plugins --skip-themes --quiet); local database_username; database_username=$("$WP_CLI_CMD" config get DB_USER --skip-plugins --skip-themes --quiet); local database_password; database_password=$("$WP_CLI_CMD" config get DB_PASSWORD --skip-plugins --skip-themes --quiet);
local dump_command; if command -v mariadb-dump &> /dev/null; then dump_command="mariadb-dump"; elif command -v mysqldump &> /dev/null; then dump_command="mysqldump"; else echo "Error: Neither mariadb-dump nor mysqldump could be found." >&2; return 1; fi
echo "Using ${dump_command} for the backup."
local backup_file="${private_directory}/database-backup-$(date +"%Y-%m-%d").sql"
if ! "${dump_command}" -u"${database_username}" -p"${database_password}" --max_allowed_packet=512M --default-character-set=utf8mb4 --add-drop-table --single-transaction --quick --lock-tables=false "${database_name}" > "${backup_file}"; then echo "Error: Database dump failed." >&2; rm -f "${backup_file}"; return 1; fi
chmod 600 "${backup_file}"; echo "✅ Database backup complete!"; echo " Backup file located at: ${backup_file}"
}
# ----------------------------------------------------
# Checks the size and contents of autoloaded options in the WordPress database.
# ----------------------------------------------------
function db_check_autoload() {
echo "Checking autoloaded options in the database..."
# Ensure the 'gum' utility is available for formatting
if ! setup_gum; then
echo "Aborting check: gum setup failed." >&2
return 1
fi
if ! setup_wp_cli; then echo "Error: wp-cli is not installed." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "Error: This does not appear to be a WordPress installation." >&2; return 1; fi
echo
echo "--- Total Autoloaded Size ---"
"$WP_CLI_CMD" db query "SELECT ROUND(SUM(LENGTH(option_value))/1024/1024, 2) as 'Autoload MB', COUNT(*) as 'Count' FROM $($WP_CLI_CMD db prefix)options WHERE autoload IN ('yes', 'on');" | "$GUM_CMD" table --print --separator $'\t'
echo
echo "--- Top 25 Autoloaded Options & Totals ---"
"$WP_CLI_CMD" db query "SELECT option_name, round(length(option_value) / 1024 / 1024, 2) as 'Size (MB)' FROM $($WP_CLI_CMD db prefix)options WHERE autoload IN ('yes', 'on') ORDER BY length(option_value) DESC LIMIT 25" | "$GUM_CMD" table --print --separator $'\t'
echo
echo "✅ Autoload check complete."
}
# ----------------------------------------------------
# Optimizes the database by converting tables to InnoDB, reporting large tables, and cleaning transients.
# ----------------------------------------------------
function db_optimize() {
# --- Pre-flight checks ---
if ! setup_gum; then
echo "Aborting optimization: gum setup failed." >&2
return 1
fi
if ! setup_wp_cli; then echo "Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "Error: This does not appear to be a WordPress installation." >&2; return 1; fi
echo "🚀 Starting database optimization..."
echo ""
# --- Step 1: Convert MyISAM to InnoDB ---
echo "--- Step 1: Checking for MyISAM tables to convert to InnoDB ---"
local myisam_tables
myisam_tables=$("$WP_CLI_CMD" db query "SELECT TABLE_NAME FROM information_schema.TABLES WHERE ENGINE = 'MyISAM' AND TABLE_SCHEMA = DATABASE()" --skip-column-names)
if [[ -z "$myisam_tables" ]]; then
echo "✅ All tables are already using the InnoDB engine. No conversion needed."
else
echo "Found the following MyISAM tables to convert:"
# Use gum to format the list of tables
"$WP_CLI_CMD" db query "SELECT TABLE_NAME AS 'MyISAM Tables' FROM information_schema.TABLES WHERE ENGINE = 'MyISAM' AND TABLE_SCHEMA = DATABASE()" | "$GUM_CMD" table --print --separator $'\t'
echo "Converting tables to InnoDB..."
"$WP_CLI_CMD" db query "SELECT CONCAT('ALTER TABLE \`', TABLE_NAME, '\` ENGINE=InnoDB;') FROM information_schema.TABLES WHERE ENGINE = 'MyISAM' AND TABLE_SCHEMA = DATABASE()" --skip-column-names | "$WP_CLI_CMD" db query
if [ $? -eq 0 ]; then
echo "✅ Successfully converted tables to InnoDB."
else
echo "❌ An error occurred during the conversion."
return 1
fi
fi
# --- Step 2: List Top 10 Largest Tables ---
echo ""
echo "--- Step 2: Top 10 Tables Larger Than 1MB ---"
# Use gum to format the table of large tables
"$WP_CLI_CMD" db query "
SELECT
TABLE_NAME,
CASE
WHEN (data_length + index_length) >= 1073741824 THEN CONCAT(ROUND((data_length + index_length) / 1073741824, 2), ' GB')
WHEN (data_length + index_length) >= 1048576 THEN CONCAT(ROUND((data_length + index_length) / 1048576, 2), ' MB')
WHEN (data_length + index_length) >= 1024 THEN CONCAT(ROUND((data_length + index_length) / 1024, 2), ' KB')
ELSE CONCAT((data_length + index_length), ' B')
END AS Size
FROM
information_schema.TABLES
WHERE
TABLE_SCHEMA = DATABASE() AND (data_length + index_length) > 1048576
ORDER BY
(data_length + index_length) DESC
LIMIT 10;
" | "$GUM_CMD" table --print --separator $'\t'
# --- Step 3: Delete Expired Transients ---
echo ""
echo "--- Step 3: Deleting Expired Transients ---"
"$WP_CLI_CMD" transient delete --expired
echo ""
echo "✅ Database optimization complete."
}
# ----------------------------------------------------
# Changes the database table prefix for a WordPress installation.
# ----------------------------------------------------
function db_change_prefix() {
echo "🚀 Starting Database Prefix Change 🚀"
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! setup_gum; then echo "❌ Error: gum is required for interactive prompts." >&2; return 1; fi
if ! command -v sed &>/dev/null; then echo "❌ Error: sed command not found." >&2; return 1; fi
# --- Get Current and New Prefix ---
local db_prefix
db_prefix=$("$WP_CLI_CMD" db prefix)
echo "Current database prefix is: $db_prefix"
local random_string
# Use a more portable random string generator to avoid locale issues.
if command -v openssl &>/dev/null; then
random_string=$(openssl rand -hex 3) # 6 hex characters
elif command -v md5sum &>/dev/null; then
random_string=$(date +%s | md5sum | head -c 5)
elif command -v sha1sum &>/dev/null; then
random_string=$(date +%s | sha1sum | head -c 5)
else
# A simpler fallback if no hashing tool is found
random_string=$(date +%s | cut -c 6-10) # last 5 digits of timestamp
fi
local db_prefix_new
db_prefix_new=$("$GUM_CMD" input --value="wp_${random_string}_" --prompt="Enter the new database prefix: ")
if [ -z "$db_prefix_new" ]; then
echo "❌ No new prefix entered. Aborting."
return 1
fi
if [ "$db_prefix" == "$db_prefix_new" ]; then
echo "❌ The new prefix is the same as the current prefix. Aborting."
return 1
fi
echo "You are about to change the database prefix from '${db_prefix}' to '${db_prefix_new}'."
"$GUM_CMD" confirm "This is a potentially destructive operation. Are you sure you want to continue?" || { echo "Operation cancelled."; return 0; }
# --- Backup Database ---
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
local db_file="${private_dir}/prefix-change-backup-$(date +"%Y-%m-%d-%H%M%S").sql"
echo "Step 1/5: Backing up database to a temporary file..."
if ! "$WP_CLI_CMD" db export "$db_file" --add-drop-table; then
echo "❌ Error: Failed to create database backup. Aborting."
return 1
fi
echo "✅ Database backup created at: $db_file"
# --- Modify SQL File ---
echo "Step 2/5: Modifying the database backup with the new prefix..."
# The -i flag for sed behaves differently on macOS/BSD vs Linux.
# Providing a backup extension (e.g., -i.bak) makes it work on both.
sed -i.bak "s#\`${db_prefix}#\`${db_prefix_new}#g" "$db_file"
sed -i.bak "s#'${db_prefix}user_roles#'${db_prefix_new}user_roles#g" "$db_file"
sed -i.bak "s#'${db_prefix}capabilities#'${db_prefix_new}capabilities#g" "$db_file"
sed -i.bak "s#'${db_prefix}user_level#'${db_prefix_new}user_level#g" "$db_file"
echo "✅ SQL file modified."
# --- Reset and Import ---
echo "Step 3/5: Resetting the database (dropping all tables)..."
"$WP_CLI_CMD" db reset --yes
echo "Step 4/5: Importing the modified database..."
if ! "$WP_CLI_CMD" db import "$db_file"; then
echo "❌ Error: Failed to import the modified database."
echo " Your original database is backed up at: $db_file"
echo " You may need to manually restore it."
# Clean up the .bak file
rm -f "${db_file}.bak"
return 1
fi
echo "✅ Database imported successfully."
# Clean up the .bak file created by sed
rm -f "${db_file}.bak"
# --- Update wp-config.php ---
echo "Step 5/5: Updating wp-config.php with the new prefix..."
if ! "$WP_CLI_CMD" config set table_prefix "$db_prefix_new" --skip-plugins --skip-themes; then
echo "❌ Error: Failed to update the table_prefix in your wp-config.php file."
echo " Please update it manually to: \$table_prefix = '$db_prefix_new';"
return 1
fi
echo "✅ wp-config.php updated."
echo ""
echo "✨ Database prefix change complete!"
echo " The backup file from before the change is still available at: $db_file"
}
# ----------------------------------------------------
# Remotely installs the Disembark plugin, connects, and initiates a backup using an embedded Playwright script.
# ----------------------------------------------------
function run_disembark() {
local target_url_input="$1"
local debug_flag="$2"
echo "🚀 Starting Disembark process for ${target_url_input}..."
# --- 1. Pre-flight Checks for disembark-cli ---
if [ -z "$target_url_input" ];then
echo "❌ Error: Missing required URL argument." >&2
show_command_help "disembark"
return 1
fi
if ! setup_disembark; then return 1; fi
# --- 2. Smart URL Parsing ---
local base_url
local login_path
# Check if the input URL contains /wp-admin or /wp-login.php
if [[ "$target_url_input" == *"/wp-admin"* || "$target_url_input" == *"/wp-login.php"* ]]; then
# If it's a backend URL, extract the base and the path
base_url=$(echo "$target_url_input" | sed -E 's#(https?://[^/]+).*#\1#')
login_path=$(echo "$target_url_input" | sed -E "s#$base_url##")
echo " - Detected backend URL. Base: '$base_url', Path: '$login_path'"
# Handle URLs with a path component that don't end in a slash (potential custom login)
elif [[ "$target_url_input" == *"/"* && "${target_url_input: -1}" != "/" ]]; then
local path_part
# Use sed -n with 'p' to ensure it only outputs on a successful match
path_part=$(echo "$target_url_input" | sed -n -E 's#https?://[^/]+(/.*)#\1#p')
# If path_part is empty, it means there was no path after the domain (e.g., https://example.com)
if [ -z "$path_part" ]; then
base_url="${target_url_input%/}"
login_path="/wp-login.php"
echo " - Homepage URL detected. Assuming default login path: '$login_path'"
# Check for deep links inside WordPress content directories
elif [[ "$path_part" == *"/wp-content"* || "$path_part" == *"/wp-includes"* ]]; then
base_url="$target_url_input"
login_path="/wp-login.php"
echo " - Deep link detected. Assuming default login for base URL."
# Otherwise, assume it's a custom login path
else
base_url=$(echo "$target_url_input" | sed -E 's#(https?://[^/]+).*#\1#')
login_path="$path_part"
echo " - Custom login path detected. Base: '$base_url', Path: '$login_path'"
fi
# Handle homepage URLs that might end with a slash
else
base_url="${target_url_input%/}" # Remove trailing slash if present
login_path="/wp-login.php"
echo " - Homepage URL detected. Assuming default login path: '$login_path'"
fi
# --- 3. Attempt backup with a potentially stored token ---
echo "✅ Attempting backup using a stored token..."
if "$DISEMBARK_CMD" backup "$base_url"; then
echo "✨ Backup successful using a pre-existing token."
return 0
fi
# --- 4. If backup fails, proceed to full browser authentication ---
echo "⚠️ Backup with stored token failed. A new connection token is likely required."
echo "Proceeding with browser authentication..."
# --- Pre-flight Checks for browser automation ---
if ! setup_playwright; then return 1; fi
if ! setup_gum; then return 1; fi
# --- Define the Playwright script using a Heredoc ---
local PLAYWRIGHT_SCRIPT
PLAYWRIGHT_SCRIPT=$(cat <<'EOF'
// --- Embedded Playwright Script ---
const { chromium } = require('playwright');
const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PLUGIN_ZIP_URL = 'https://github.com/DisembarkHost/disembark-connector/releases/latest/download/disembark-connector.zip';
async function main() {
const [, , baseUrl, loginPath, username, password, debugFlag] = process.argv;
if (!baseUrl || !loginPath || !username || !password) {
console.error('Usage: node disembark-browser.js <baseUrl> <loginPath> <username> <password> [debug]');
process.exit(1);
}
const loginUrl = baseUrl + loginPath;
const adminUrl = baseUrl + '/wp-admin/';
const isHeadless = debugFlag !== 'true';
const browser = await chromium.launch({ headless: isHeadless });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
});
const page = await context.newPage();
try {
// 1. LOGIN
process.stdout.write(` - Step 1/5: Authenticating with WordPress at ${loginUrl}...`);
await page.goto(loginUrl, { waitUntil: 'domcontentloaded' });
if (!(await page.isVisible('#user_login'))) {
console.log(' Failed.');
console.error('LOGIN_URL_INVALID');
process.exit(2);
}
await page.fill('#user_login', username);
await page.fill('#user_pass', password);
await page.click('#wp-submit');
try {
await page.waitForSelector('#wpadminbar, #login_error, #correct-admin-email', { timeout: 60000 });
} catch (e) {
throw new Error('Authentication timed out. The page did not load the admin bar, a login error, or the admin email confirmation screen.');
}
if (await page.isVisible('#login_error')) {
const errorText = await page.locator('#login_error').textContent();
console.error(`LOGIN_FAILED: ${errorText.trim()}`);
process.exit(3);
}
if (await page.isVisible('#correct-admin-email')) {
process.stdout.write(' Admin email confirmation required. Submitting...');
await page.click('#correct-admin-email');
await page.waitForSelector('#wpadminbar', { timeout: 60000 });
}
if (!(await page.isVisible('#wpadminbar'))) {
throw new Error('Authentication failed. Admin bar not found after login.');
}
// --- Recovery Step: Navigate to the main dashboard to bypass any welcome/update screens ---
if (!page.url().startsWith(adminUrl)) {
process.stdout.write(' Navigating to main dashboard to bypass intermediate pages...');
await page.goto(adminUrl, { waitUntil: 'networkidle' });
await page.waitForSelector('#wpadminbar'); // Re-confirm we are in the admin area
process.stdout.write(' Done.');
}
console.log(' Success!');
// 2. CHECK IF PLUGIN EXISTS & ACTIVATE IF NEEDED
process.stdout.write(' - Step 2/5: Checking plugin status...');
await page.goto(`${adminUrl}plugins.php`, { waitUntil: 'networkidle' });
const pluginRow = page.locator('tr[data-slug="disembark-connector"]');
if (await pluginRow.count() > 0) {
console.log(' Plugin found.');
const activateLink = pluginRow.locator('a.edit:has-text("Activate")');
if (await activateLink.count() > 0) {
process.stdout.write(' - Activating existing plugin...');
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
activateLink.click()
]);
console.log(' Activated!');
} else {
console.log(' - Plugin already active.');
}
} else {
// 3. UPLOAD AND INSTALL PLUGIN
console.log(' Plugin not found, proceeding with installation.');
process.stdout.write(' - Step 3/5: Downloading plugin...');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'disembark-'));
const pluginZipPath = path.join(tempDir, 'disembark-connector.zip');
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(pluginZipPath);
const request = (url) => {
https.get(url, (response) => {
if (response.statusCode > 300 && response.statusCode < 400 && response.headers.location) {
request(response.headers.location);
} else {
response.pipe(file);
file.on('finish', () => file.close(resolve));
}
}).on('error', (err) => {
fs.unlinkSync(pluginZipPath);
reject(err);
});
};
request(PLUGIN_ZIP_URL);
});
console.log(' Download complete.');
process.stdout.write(' - Uploading and installing...');
await page.goto(`${adminUrl}plugin-install.php?tab=upload`);
await page.setInputFiles('input#pluginzip', pluginZipPath);
await page.waitForSelector('input#install-plugin-submit:not([disabled])', { timeout: 10000 });
await page.click('input#install-plugin-submit');
const activationLinkSelector = 'a:has-text("Activate Plugin"), a.activate-now, .button.activate-now';
const alreadyInstalledSelector = 'body:has-text("Destination folder already exists.")';
const mixedSuccessSelector = 'body:has-text("Plugin installed successfully.")';
const genericErrorSelector = '.wrap > .error, .wrap > #message.error';
try {
await page.waitForSelector(
`${activationLinkSelector}, ${alreadyInstalledSelector}, ${mixedSuccessSelector}, ${genericErrorSelector}`,
{ timeout: 90000 }
);
} catch (e) {
throw new Error('Timed out waiting for a response after clicking "Install Now".');
}
if (await page.locator(activationLinkSelector).count() > 0) {
const activateButton = page.locator(activationLinkSelector);
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
activateButton.first().click(),
]);
console.log(' Installed & Activated!');
} else if (await page.locator(alreadyInstalledSelector).count() > 0) {
console.log(' Plugin already installed.');
} else if (await page.locator(mixedSuccessSelector).count() > 0) {
console.log(' Install succeeded, but page reported an error. Navigating to plugins page to activate...');
await page.goto(`${adminUrl}plugins.php`, { waitUntil: 'networkidle' });
const pluginRow = page.locator('tr[data-slug="disembark-connector"]');
const activateLink = pluginRow.locator('a.edit:has-text("Activate")');
if (await activateLink.count() > 0) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
activateLink.click()
]);
console.log(' Activated!');
} else {
console.log(' - Plugin was already active on the plugins page.');
}
} else {
const errorText = await page.locator(genericErrorSelector).first().textContent();
throw new Error(`Plugin installation failed with a WordPress error: ${errorText.trim()}`);
}
fs.unlinkSync(pluginZipPath);
fs.rmdirSync(tempDir);
}
// 4. RETRIEVE TOKEN
process.stdout.write(' - Step 4/5: Retrieving connection token...');
const tokenPageUrl = `${adminUrl}plugin-install.php?tab=plugin-information&plugin=disembark-connector`;
await page.goto(tokenPageUrl, { waitUntil: 'networkidle' });
const tokenElement = page.locator('div#section-description > code');
if (await tokenElement.count() === 0) {
throw new Error('Could not find the connection token element on the page.');
}
const token = await tokenElement.first().textContent();
console.log(' Token found!');
// 5. OUTPUT TOKEN FOR BASH SCRIPT
process.stdout.write(' - Step 5/5: Sending token back to script...');
console.log(token.trim());
} catch (error) {
console.error(`\nError: ${error.message}`);
process.exit(1);
} finally {
await browser.close();
}
}
main();
EOF
)
# --- Helper function for getting credentials ---
_get_credentials() {
echo "Please provide WordPress administrator credentials:"
username=$("$GUM_CMD" input --placeholder="Enter WordPress username...")
if [ -z "$username" ]; then
echo "No username provided. Aborting." >&2
return 1
fi
password=$("$GUM_CMD" input --placeholder="Enter WordPress password..." --password)
if [ -z "$password" ]; then
echo "No password provided. Aborting." >&2
return 1
fi
return 0
}
# --- Helper for running playwright ---
run_playwright_and_get_token() {
local url_to_run="$1"
local path_to_run="$2"
local user_to_run="$3"
local pass_to_run="$4"
echo "🤖 Launching browser to automate login and plugin setup..."
echo " - Attempting login at: ${url_to_run}${path_to_run}"
set -o pipefail
local full_output
full_output=$(echo "$PLAYWRIGHT_SCRIPT" | node - "$url_to_run" "$path_to_run" "$user_to_run" "$pass_to_run" "$debug_flag" | tee /dev/tty)
local exit_code=$?
set +o pipefail
if [ $exit_code -eq 3 ]; then
return 3 # Bad credentials
elif [ $exit_code -eq 2 ]; then
return 2 # Invalid URL
elif [ $exit_code -ne 0 ]; then
return 1 # General failure
fi
echo "$full_output" | tail -n 1 | tr -d '[:space:]'
return 0
}
# --- Authentication Loop ---
local token
local playwright_exit_code
# Initial credential prompt
if ! _get_credentials; then return 1; fi
while true; do
token=$(run_playwright_and_get_token "$base_url" "$login_path" "$username" "$password")
playwright_exit_code=$?
if [ $playwright_exit_code -eq 0 ]; then
break # Success
elif [ $playwright_exit_code -eq 2 ]; then # Invalid URL
echo "⚠️ The login URL '${base_url}${login_path}' appears to be incorrect."
local new_login_url
new_login_url=$("$GUM_CMD" input --placeholder="Enter the full, correct WordPress Admin URL...")
if [ -z "$new_login_url" ]; then
echo "No URL provided. Aborting." >&2; return 1;
fi
base_url=$(echo "$new_login_url" | sed -E 's#(https?://[^/]+).*#\1#')
login_path=$(echo "$new_login_url" | sed -E "s#$base_url##")
continue # Retry loop with new URL
elif [ $playwright_exit_code -eq 3 ]; then # Bad credentials
echo "⚠️ Login failed. The credentials may be incorrect."
if "$GUM_CMD" confirm "Re-enter credentials and try again?"; then
if ! _get_credentials; then return 1; fi
continue # Retry loop with new credentials
else
echo "Authentication cancelled." >&2; return 1;
fi
else # General failure
echo "❌ Browser automation failed. Please check errors above." >&2
return 1
fi
done
# --- Final Check and Backup ---
if [ $playwright_exit_code -ne 0 ] || [ -z "$token" ]; then
echo "❌ Could not retrieve a token. Aborting." >&2
return 1
fi
echo "✅ Browser automation successful. Token retrieved."
echo "📞 Connecting and starting backup with disembark-cli..."
if ! "$DISEMBARK_CMD" connect "${base_url}" "${token}"; then
echo "❌ Error: Failed to connect using the retrieved token." >&2
return 1
fi
if ! "$DISEMBARK_CMD" backup "${base_url}"; then
echo "❌ Error: Backup command failed after connecting." >&2
return 1
fi
echo "✨ Disembark process complete!"
}
# ----------------------------------------------------
# Dumps the content of files matching a pattern into a single text file.
# ----------------------------------------------------
function run_dump() {
# --- 1. Validate Input ---
local INPUT_PATTERN="$1"
shift
local exclude_patterns=("$@")
if [ -z "$INPUT_PATTERN" ];
then
echo "Error: No input pattern provided." >&2
echo "Usage: _do dump \"<path/to/folder/*.extension>\" [-x <exclude_pattern>]..." >&2
return 1
fi
# --- 2. Determine Paths and Names ---
local SEARCH_DIR
SEARCH_DIR=$(dirname "$INPUT_PATTERN")
local FILE_PATTERN
FILE_PATTERN=$(basename "$INPUT_PATTERN")
local OUTPUT_BASENAME
OUTPUT_BASENAME=$(basename "$SEARCH_DIR")
local OUTPUT_FILE
if [ "$OUTPUT_BASENAME" == "." ]; then
# If searching in the current dir, use the folder's name for the output file.
OUTPUT_BASENAME=$(basename "$(pwd)")
OUTPUT_FILE="${OUTPUT_BASENAME}-dump.txt"
else
OUTPUT_FILE="${OUTPUT_BASENAME}.txt"
fi
# --- 3. Process Files ---
> "$OUTPUT_FILE"
echo "Searching in '$SEARCH_DIR' for files matching '$FILE_PATTERN'..."
if [ ${#exclude_patterns[@]} -gt 0 ]; then
echo "Excluding user-defined patterns: ${exclude_patterns[*]}"
fi
echo "Automatically excluding: .git directory contents"
# Dynamically build the find command
local find_cmd=("find" "$SEARCH_DIR" "-type" "f" "-name" "$FILE_PATTERN")
# Add user-defined exclusions
for pattern in "${exclude_patterns[@]}"; do
if [[ "$pattern" == */ ]]; then
# For directories (pattern ends with /), use -path
local dir_pattern=${pattern%/} # remove trailing slash
find_cmd+=("-not" "-path" "*/$dir_pattern/*")
else
# For files, use -name
find_cmd+=("-not" "-name" "$pattern")
fi
done
# Add automatic exclusions for the output file and .git directory
find_cmd+=("-not" "-name" "$OUTPUT_FILE")
find_cmd+=("-not" "-path" "*/.git/*")
# Execute the find command
"${find_cmd[@]}" -print0 | while IFS= read -r -d '' file; do
echo "--- File: $file ---" >> "$OUTPUT_FILE"
cat "$file" >> "$OUTPUT_FILE"
echo -e "\n" >> "$OUTPUT_FILE"
done
# --- 4. Final Report ---
if [ ! -s "$OUTPUT_FILE" ]; then
echo "Warning: No files found matching the pattern. No dump file created."
rm "$OUTPUT_FILE"
return 0
fi
local FILE_SIZE
FILE_SIZE=$(du -h "$OUTPUT_FILE" | cut -f1 | xargs)
# --- WordPress URL Logic ---
local dump_url=""
# Silently check if WP-CLI is available and we're in a WordPress installation.
if setup_wp_cli &>/dev/null && "$WP_CLI_CMD" core is-installed --quiet 2>/dev/null; then
local wp_home
wp_home=$("$WP_CLI_CMD" option get home --skip-plugins --skip-themes 2>/dev/null)
# We need `realpath` for this to work reliably
if [ -n "$wp_home" ] && command -v realpath &>/dev/null; then
local wp_root_path
# Use `wp config path` to get the wp-config.php path, which is more reliable.
wp_root_path=$("$WP_CLI_CMD" config path --quiet 2>/dev/null)
# Only proceed if we found a valid wp-config.php path.
if [ -n "$wp_root_path" ] && [ -f "$wp_root_path" ]; then
wp_root_path=$(dirname "$wp_root_path")
local current_path
current_path=$(realpath ".")
# Get the path of the current directory relative to the WordPress root
local relative_path
relative_path=${current_path#"$wp_root_path"}
# Construct the final URL
# This ensures no double slashes and correctly handles the root directory case
dump_url="${wp_home%/}${relative_path}/${OUTPUT_FILE}"
fi
fi
fi
# --- End WordPress URL Logic ---
echo "Generated $OUTPUT_FILE ($FILE_SIZE)"
if [ -n "$dump_url" ]; then
echo "URL: $dump_url"
fi
}
# ----------------------------------------------------
# Sends an email using wp_mail.
# ----------------------------------------------------
function run_email() {
echo "🚀 Preparing to send an email via WP-CLI..."
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! setup_gum; then echo "Aborting: gum setup failed." >&2; return 1; fi
# --- Gather Email Details with Gum ---
local to_email
to_email=$("$GUM_CMD" input --placeholder="Recipient email address...")
if [ -z "$to_email" ]; then echo "❌ No email address provided. Aborting."; return 1; fi
local subject
subject=$("$GUM_CMD" input --placeholder="Email subject...")
if [ -z "$subject" ]; then echo "❌ No subject provided. Aborting."; return 1; fi
local content
echo "Enter email content (press Ctrl+D when finished):"
content=$("$GUM_CMD" write)
if [ -z "$content" ]; then echo "❌ No content provided. Aborting."; return 1; fi
# --- Construct and Execute Command ---
echo "Sending email..."
# Escape single quotes in the variables to prevent breaking the wp eval command
local escaped_to_email; escaped_to_email=$(printf "%s" "$to_email" | sed "s/'/\\\'/g")
local escaped_subject; escaped_subject=$(printf "%s" "$subject" | sed "s/'/\\\'/g")
local escaped_content; escaped_content=$(printf "%s" "$content" | sed "s/'/\\\'/g")
local wp_command="wp_mail( '$escaped_to_email', '$escaped_subject', '$escaped_content', ['Content-Type: text/html; charset=UTF-8'] );"
# Use a temporary variable to capture the output from wp eval
local eval_output
eval_output=$("$WP_CLI_CMD" eval "$wp_command" 2>&1)
local exit_code=$?
# The `wp_mail` function in WordPress returns `true` on success and `false` on failure.
# However, `wp eval` doesn't directly translate this boolean to an exit code.
# We can check the output. A successful `wp_mail` call via `wp eval` usually produces no output.
# A failure might produce a PHP error or warning.
if [ $exit_code -eq 0 ]; then
echo "✅ Email command sent successfully to $to_email."
echo " Please check the recipient's inbox and the mail server logs to confirm delivery."
else
echo "❌ Error: The 'wp eval' command failed. Please check your WordPress email configuration."
echo " WP-CLI output:"
echo "$eval_output"
return 1
fi
}
# ----------------------------------------------------
# Finds files that have been recently modified.
# ----------------------------------------------------
function find_recent_files() {
local days="${1:-1}" # Default to 1 day if no argument is provided
# Validate that the input is a number
if ! [[ "$days" =~ ^[0-9]+$ ]]; then
echo "❌ Error: Please provide a valid number of days." >&2
echo "Usage: _do find recent-files <days>" >&2
return 1
fi
echo "🔎 Searching for files modified in the last $days day(s)..."
echo
# Check the operating system to use the correct `find` command syntax.
# The `-printf` option is not available on macOS/BSD `find`.
if [[ "$(uname)" == "Darwin" ]]; then
# On macOS, use -exec with `stat` for formatted, sortable output.
# -f "%Sm %N" formats the output: Modification time, then file name.
# -t "%Y-%m-%d %H:%M:%S" specifies the timestamp format for sorting.
find . -type f -mtime "-${days}" -exec stat -f "%Sm %N" -t "%Y-%m-%d %H:%M:%S" {} + | sort -r
else
# On Linux, the more efficient -printf option is available.
find . -type f -mtime "-${days}" -printf "%TY-%Tm-%Td %TH:%M:%S %p\n" | sort -r
fi
if [ $? -ne 0 ]; then
echo "❌ Error: The 'find' command failed to execute." >&2
return 1
fi
}
# ----------------------------------------------------
# Identifies plugins that may be slowing down WP-CLI command execution.
# ----------------------------------------------------
function find_slow_plugins() {
local page_to_check="${1}"
_get_wp_execution_time() {
local output
output=$("$WP_CLI_CMD" "$@" --debug 2>&1)
echo "$output" | perl -ne '/Debug \(bootstrap\): Running command: .+\(([^s]+s)/ && print $1'
}
if ! setup_wp_cli; then echo "❌ Error: WP-CLI (wp command) not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
local base_command_args=("plugin" "list")
local skip_argument="--skip-plugins"
local description="wp plugin list --debug"
if [ -n "$page_to_check" ]; then
echo "🚀 WordPress Plugin Performance Test for page: '$page_to_check' 🚀"
description="wp render \"$page_to_check\" --debug"
# Check for wp render and install if missing
if ! "$WP_CLI_CMD" help render &> /dev/null; then
echo " 'wp render' command not found. Installing 'render-command' plugin..."
if ! "$WP_CLI_CMD" plugin install https://github.com/austinginder/render-command/releases/latest/download/render-command.zip --activate --force; then
echo "❌ Error: Failed to install 'render-command' plugin. Aborting." >&2
return 1
fi
echo "✅ 'render-command' plugin installed successfully."
fi
# Update command variables to use 'wp render'
base_command_args=("render" "$page_to_check")
skip_argument="--without-plugin"
else
echo "🚀 WordPress Plugin Performance Test 🚀"
fi
echo "This script measures the execution time of '$description' under various conditions."
echo ""
echo "📋 Initial Baseline Measurements for '$description':"
local base_time_s
printf " ⏳ Measuring base time (ALL plugins & theme active)... "
base_time_s=$(_get_wp_execution_time "${base_command_args[@]}")
if [[ -z "$base_time_s" ]]; then
echo "❌ Error: Could not measure base execution time." >&2
return 1
fi
echo "Base time: $base_time_s"
echo ""
local active_plugins=()
while IFS= read -r line; do
active_plugins+=("$line")
done < <("$WP_CLI_CMD" plugin list --field=name --status=active)
if [[ ${#active_plugins[@]} -eq 0 ]]; then
echo " No active plugins found to test."
return 0
fi
echo "📊 Measuring impact of individual plugins (compared to '${base_time_s}' base time):"
echo "A larger positive 'Impact' suggests the plugin contributes more to the load time of this specific WP-CLI command."
echo "---------------------------------------------------------------------------------"
printf "%-40s | %-15s | %-15s\n" "Plugin Skipped" "Time w/ Skip" "Impact (Base-Skip)"
echo "---------------------------------------------------------------------------------"
local results=()
for plugin in "${active_plugins[@]}"; do
# Skip the render-command plugin itself when using the render method
if [[ -n "$page_to_check" && "$plugin" == "render-command" ]]; then
continue
fi
local time_with_skip_s
time_with_skip_s=$(_get_wp_execution_time "${base_command_args[@]}" "${skip_argument}=$plugin")
if [[ -n "$time_with_skip_s" ]]; then
local diff_s
diff_s=$(awk -v base="${base_time_s%s}" -v skip="${time_with_skip_s%s}" 'BEGIN { printf "%.3f", base - skip }')
local impact_sign=""
if [[ $(awk -v diff="$diff_s" 'BEGIN { print (diff > 0) }') -eq 1 ]]; then
impact_sign="+"
fi
results+=("$(printf "%.3f" "$diff_s")|$plugin|$time_with_skip_s|${impact_sign}${diff_s}s")
else
results+=("0.000|$plugin|Error|Error measuring")
fi
done
local sorted_results=()
while IFS= read -r line; do
sorted_results+=("$line")
done < <(printf "%s\n" "${results[@]}" | sort -t'|' -k1,1nr)
for result_line in "${sorted_results[@]}"; do
local p_name
p_name=$(echo "$result_line" | cut -d'|' -f2)
local t_skip
t_skip=$(echo "$result_line" | cut -d'|' -f3)
local i_str
i_str=$(echo "$result_line" | cut -d'|' -f4)
printf "%-40s | %-15s | %-15s\n" "$p_name" "$t_skip" "$i_str"
done
echo "---------------------------------------------------------------------------------"
echo ""
echo "✅ Test Complete"
echo "💡 Note: This measures impact on a specific WP-CLI command. For front-end or"
echo " admin profiling, consider using a plugin like Query Monitor or New Relic."
echo ""
}
# ----------------------------------------------------
# Detects plugins that are active but hidden from the standard plugin list.
# ----------------------------------------------------
function find_hidden_plugins() {
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! setup_gum; then return 1; fi
echo "🚀 Checking for hidden WordPress plugins..."
# Get the standard list of active plugins
local active_plugins
active_plugins=$("$WP_CLI_CMD" plugin list --field=name --status=active)
# Get the "raw" list of active plugins by skipping themes and other plugins
local active_plugins_raw
active_plugins_raw=$("$WP_CLI_CMD" plugin list --field=name --status=active --skip-themes --skip-plugins)
local regular_count
regular_count=$(echo "$active_plugins" | wc -l | xargs)
local raw_count
raw_count=$(echo "$active_plugins_raw" | wc -l | xargs)
# Compare the counts of the two lists.
if [[ "$regular_count" == "$raw_count" ]]; then
echo "✅ No hidden plugins detected. The standard and raw plugin lists match ($regular_count plugins)."
return 0
fi
# If the counts differ, find the plugins that are in the raw list but not the standard one.
echo "⚠️ Found a discrepancy between plugin lists!"
echo " - Standard list shows: $regular_count active plugins."
echo " - Raw list shows: $raw_count active plugins."
echo
# Use 'comm' to find lines unique to the raw list.
local hidden_plugins
hidden_plugins=$(comm -13 <(echo "$active_plugins" | sort) <(echo "$active_plugins_raw" | sort))
if [ -z "$hidden_plugins" ]; then
echo " Could not isolate the specific hidden plugins, but a discrepancy exists."
else
echo "--- Found Hidden Plugin(s) ---"
# Loop through each hidden plugin and get its details
while IFS= read -r plugin; do
if [ -z "$plugin" ]; then continue; fi
"$GUM_CMD" log --level warn "Details for: $plugin"
# Get plugin details in CSV format and pipe to gum for a clean table printout.
"$WP_CLI_CMD" plugin get "$plugin" --skip-plugins --skip-themes --format=csv | \
"$GUM_CMD" table --separator "," --widths=15,0 --print
echo
done <<< "$hidden_plugins"
echo "💡 These plugins are active but may be hidden from the admin view or standard WP-CLI list."
echo " Common offenders are management plugins (like ManageWP's 'worker') or potentially malicious code."
fi
}
# ----------------------------------------------------
# Scans for potential malware and verifies WordPress core/plugin integrity.
# ----------------------------------------------------
function find_malware() {
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! command -v grep &> /dev/null; then echo "❌ Error: 'grep' command not found." >&2; return 1; fi
if ! setup_gum; then return 1; fi
echo "🚀 Starting malware scan..."
echo "This process will check for suspicious code patterns and verify core/plugin file integrity."
echo
# --- 1. Hunt for suspicious PHP code ---
echo "--- Step 1/3: Searching for suspicious PHP code patterns... ---"
local suspicious_patterns=(
'eval(base64_decode('
'eval(gzinflate('
'eval(gzuncompress('
'eval(str_rot13('
'preg_replace.*\/e'
'create_function'
'FilesMan'
'c99shell'
'r57shell'
'shell_exec('
'passthru('
'system('
'phpinfo('
'assert('
)
local found_suspicious_files=false
local combined_pattern
combined_pattern=$(IFS='|'; echo "${suspicious_patterns[*]}")
local search_results
search_results=$(grep -rn --include='*.php' -iE "$combined_pattern" . 2>/dev/null)
if [ -n "$search_results" ]; then
echo "⚠️ Found potentially malicious code in the following files:"
echo "-----------------------------------------------------"
echo "$search_results"
echo "-----------------------------------------------------"
echo "💡 Review these files carefully. They may contain legitimate code that matches these patterns."
found_suspicious_files=true
else
echo "✅ No suspicious code patterns found."
fi
echo
# --- 2. Verify WordPress Core Checksums ---
echo "--- Step 2/3: Verifying WordPress core file integrity... ---"
if ! "$WP_CLI_CMD" core verify-checksums --skip-plugins --skip-themes; then
echo "⚠️ WordPress core verification failed. The files listed above may have been modified."
else
echo "✅ WordPress core files verified successfully."
fi
echo
# --- 3. Verify Plugin Checksums ---
echo "--- Step 3/3: Verifying plugin file integrity (from wordpress.org)... ---"
local stderr_file_plugin
stderr_file_plugin=$(mktemp)
local plugin_csv_data
plugin_csv_data=$("$WP_CLI_CMD" plugin verify-checksums --all --format=csv --skip-plugins --skip-themes --quiet 2> "$stderr_file_plugin")
local plugin_checksum_status=$?
local plugin_summary_error
plugin_summary_error=$(cat "$stderr_file_plugin")
rm "$stderr_file_plugin"
if [ $plugin_checksum_status -ne 0 ]; then
echo "⚠️ Plugin verification encountered an error or found mismatches."
# Check if there is any CSV data to display
if [[ -n "$plugin_csv_data" ]]; then
echo "$plugin_csv_data" | "$GUM_CMD" table --separator "," --print
fi
# Display the summary error if it exists
if [ -n "$plugin_summary_error" ]; then
echo "$plugin_summary_error"
fi
echo "💡 This may include plugins not in the wordpress.org directory (premium plugins) or modified files."
else
echo "✅ All plugins from wordpress.org verified successfully."
fi
echo
echo "✅ Malware scan complete."
}
# ----------------------------------------------------
# Finds outdated or invalid PHP opening tags in PHP files.
# ----------------------------------------------------
function find_outdated_php_tags() {
local search_dir="${1:-.}"
# Ensure the search directory ends with a slash for consistency
if [[ "${search_dir: -1}" != "/" ]]; then
search_dir+="/"
fi
if [ ! -d "$search_dir" ]; then
echo "❌ Error: Directory '$search_dir' not found." >&2
return 1
fi
echo "🚀 Searching for outdated PHP tags in '${search_dir}'..."
echo "This can take a moment for large directories."
echo
# This new, more portable method first finds all lines with '<?',
# then filters out the lines containing valid tags like '<?php', '<?=', or '<?xml'.
# This avoids reliance on potentially unsupported 'grep -P' features.
local initial_results
initial_results=$(grep --include="*.php" --line-number --recursive '<?' "$search_dir" 2>/dev/null \
| grep -v -F -e '<?php' -e '<?=' -e '<?xml' \
| grep --color=always -e '<?' -e '$^'
)
# Filter out common false positives from comments, strings, etc.
local found_tags
found_tags=$(echo "$initial_results" \
| grep -v -F -e "strpos(" -e "str_replace(" \
| grep -v -E "^[^:]*:[^:]*:\s*(\*|//|#)|'\<\?'|\"\<\?\"" \
)
if [ -z "$found_tags" ]; then
echo "✅ No outdated PHP tags were found (after filtering common false positives)."
else
echo "⚠️ Found potentially outdated PHP tags in the following files:"
echo "-----------------------------------------------------"
# The output from grep is already well-formatted.
echo "$found_tags"
echo "-----------------------------------------------------"
# Use single quotes instead of backticks to prevent command execution.
echo "Recommendation: Replace all short tags like '<?' with the full '<?php' tag."
fi
}
# ----------------------------------------------------
# Applies HTTPS to a WordPress site's URLs.
# ----------------------------------------------------
function run_https() {
echo "🚀 Applying HTTPS to site URLs..."
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! setup_gum; then echo "❌ Error: gum is required for interactive prompts." >&2; return 1; fi
# --- Get Base Domain ---
# Strips http/https and www to get a clean domain name.
local domain
domain=$("$WP_CLI_CMD" option get home)
domain=${domain/http:\/\/www./}
domain=${domain/https:\/\/www./}
domain=${domain/http:\/\//}
domain=${domain/https:\/\//}
# Trim whitespace just in case
domain=$( echo "$domain" | awk '{$1=$1};1' )
# --- Ask User Preference for 'www' ---
local use_www=false
# Ask the user, with "No" as the default option.
# If they select "Yes" (exit code 0), then set use_www to true.
if "$GUM_CMD" confirm "Should the new HTTPS URL include 'www.'?" --default=false; then
use_www=true
fi
# --- Define Target URLs ---
local new_url
local new_url_escaped
if [ "$use_www" = true ]; then
new_url="https://www.$domain"
new_url_escaped="https:\/\/www.$domain"
else
new_url="https://$domain"
new_url_escaped="https:\/\/$domain"
fi
echo "This will update all URLs to use '$new_url'."
"$GUM_CMD" confirm "Proceed with search and replace?" || { echo "Operation cancelled by user."; return 0; }
# --- Run Replacements ---
echo "1/4: Replacing http://$domain ..."
"$WP_CLI_CMD" search-replace "http://$domain" "$new_url" --all-tables --skip-plugins --skip-themes --report-changed-only
echo "2/4: Replacing http://www.$domain ..."
"$WP_CLI_CMD" search-replace "http://www.$domain" "$new_url" --all-tables --skip-plugins --skip-themes --report-changed-only
echo "3/4: Replacing escaped http:\/\/$domain ..."
"$WP_CLI_CMD" search-replace "http:\/\/$domain" "$new_url_escaped" --all-tables --skip-plugins --skip-themes --report-changed-only
echo "4/4: Replacing escaped http:\/\/www.$domain ..."
"$WP_CLI_CMD" search-replace "http:\/\/www.$domain" "$new_url_escaped" --all-tables --skip-plugins --skip-themes --report-changed-only
echo "Flushing WordPress cache..."
"$WP_CLI_CMD" cache flush
echo ""
echo "✅ HTTPS migration complete! All URLs updated to '$new_url'."
}
# ----------------------------------------------------
# Installs helper and premium plugins.
# ----------------------------------------------------
# ----------------------------------------------------
# Installs the Kinsta Must-Use plugin.
# ----------------------------------------------------
function install_kinsta_mu() {
local force_flag="$1"
# Check if this is a Kinsta environment unless --force is used
if [[ "$force_flag" != "true" ]]; then
if [ ! -f "/etc/update-motd.d/00-kinsta-welcome" ]; then
echo " This does not appear to be a Kinsta environment. Skipping installation." >&2
echo " Use the --force flag to install anyway." >&2
return 0
fi
else
echo "✅ --force flag detected. Skipping Kinsta environment check."
fi
echo "🚀 Installing Kinsta MU plugin..."
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! command -v wget &>/dev/null; then echo "❌ Error: wget not found." >&2; return 1; fi
if ! command -v unzip &>/dev/null; then echo "❌ Error: unzip not found." >&2; return 1; fi
# Get wp-content path dynamically for reliability
local wp_content_dir
wp_content_dir=$("$WP_CLI_CMD" eval "echo rtrim(WP_CONTENT_DIR, '/');" --skip-plugins --skip-themes 2>/dev/null)
if [ -z "$wp_content_dir" ] || [ ! -d "$wp_content_dir" ]; then
echo "❌ Error: Could not determine wp-content directory." >&2
return 1
fi
local mu_plugins_dir="${wp_content_dir}/mu-plugins"
if [ ! -d "$mu_plugins_dir" ]; then
echo "Creating directory: $mu_plugins_dir"
mkdir -p "$mu_plugins_dir"
fi
# --- Installation ---
local kinsta_zip_file="kinsta-mu-plugins.zip"
# Download to the private directory to avoid clutter
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
local temp_zip_path="${private_dir}/${kinsta_zip_file}"
if wget -q https://kinsta.com/kinsta-tools/kinsta-mu-plugins.zip -O "$temp_zip_path"; then
unzip -o -q "$temp_zip_path" -d "$mu_plugins_dir/"
rm "$temp_zip_path"
echo "✅ Kinsta MU plugin installed successfully to ${mu_plugins_dir}/."
else
echo "❌ Error: Could not download the Kinsta MU plugin."
# Clean up failed download
[ -f "$temp_zip_path" ] && rm "$temp_zip_path"
return 1
fi
}
# ----------------------------------------------------
# Installs the CaptainCore Helper plugin.
# ----------------------------------------------------
function install_helper() {
echo "🚀 Deploying CaptainCore Helper..."
# --- Pre-flight Checks ---
if ! command -v curl &>/dev/null; then echo "❌ Error: curl not found." >&2; return 1; fi
# --- Deployment ---
if curl -sSL https://run.captaincore.io/deploy-helper | bash -s; then
echo "✅ CaptainCore Helper deployed successfully."
else
echo "❌ Error: Failed to deploy CaptainCore Helper."
return 1
fi
}
# ----------------------------------------------------
# Installs The Events Calendar Pro.
# ----------------------------------------------------
function install_events_calendar_pro() {
echo "🚀 Installing The Events Calendar Pro..."
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! setup_gum; then echo "Aborting: gum setup failed." >&2; return 1; fi
# --- Get License Key ---
local license_key
license_key=$("$GUM_CMD" input --placeholder="Enter Events Calendar Pro license key..." --password)
if [ -z "$license_key" ]; then
echo "❌ No license key provided. Aborting installation." >&2
return 1
fi
# --- Installation Steps ---
echo "Step 1/3: Installing 'The Events Calendar' (free version)..."
if ! "$WP_CLI_CMD" plugin install the-events-calendar --force --activate; then
echo "❌ Error: Failed to install the free version of The Events Calendar." >&2
return 1
fi
echo "Step 2/3: Saving license key..."
if ! "$WP_CLI_CMD" option update pue_install_key_events_calendar_pro "$license_key"; then
echo "❌ Error: Failed to save the license key to the database." >&2
return 1
fi
echo "Step 3/3: Installing 'Events Calendar Pro'..."
local pro_plugin_url="https://pue.tri.be/api/plugins/v2/download?plugin=events-calendar-pro&key=$license_key"
if ! "$WP_CLI_CMD" plugin install "$pro_plugin_url" --force --activate; then
echo "❌ Error: Failed to install Events Calendar Pro. Please check your license key." >&2
return 1
fi
echo "✅ The Events Calendar Pro installed and activated successfully."
}
# ----------------------------------------------------
# Launches site - updates url from dev to live,
# enables search engine visibility, and clears cache.
# ----------------------------------------------------
function run_launch() {
# The domain is passed as the first argument, skip_confirm as the second
local domain="$1"
local skip_confirm="$2"
echo "🚀 Launching Site"
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! setup_gum; then echo "❌ Error: gum is required for interactive prompts." >&2; return 1; fi
# --- Get New Domain ---
if [ -z "$domain" ]; then
# If no argument is passed, prompt interactively
domain=$("$GUM_CMD" input --placeholder "Enter the new live domain (e.g., example.com)...")
else
# If an argument is passed, just confirm the value
echo "Using provided domain: $domain"
fi
if [ -z "$domain" ]; then
echo "No domain entered. Launch cancelled."
return 1
fi
# --- Get Current Domain ---
local current_domain
current_domain=$("$WP_CLI_CMD" option get home --skip-plugins --skip-themes)
# Strip protocols
current_domain=${current_domain/http:\/\//}
current_domain=${current_domain/https:\/\//}
# Trim whitespace
current_domain=$(echo "$current_domain" | awk '{$1=$1};1')
if [[ -z "$current_domain" ]] || [[ "$current_domain" != *"."* ]]; then
echo "❌ Error: Could not find a valid existing domain from the WordPress installation." >&2
return 1
fi
if [[ "$current_domain" == "$domain" ]]; then
echo "The new domain is the same as the current domain. No changes needed."
return 0
fi
# --- Confirmation ---
# Only ask for confirmation if the skip_confirm flag is not true
if [[ "$skip_confirm" != "true" ]]; then
echo "This will update the site URL from '$current_domain' to '$domain'."
"$GUM_CMD" confirm "Proceed with launch?" || { echo "Operation cancelled by user."; return 0; }
fi
# --- Run URL Replacements ---
echo "1/2: Running search and replace for URLs..."
"$WP_CLI_CMD" search-replace "//$current_domain" "//$domain" --all-tables --skip-plugins --skip-themes --report-changed-only
echo "2/2: Running search and replace for escaped URLs..."
"$WP_CLI_CMD" search-replace "\/\/${current_domain}" "\/\/$domain" --all-tables --skip-plugins --skip-themes --report-changed-only
# --- Final Steps ---
echo "Enabling search engine visibility..."
"$WP_CLI_CMD" option update blog_public 1 --skip-plugins --skip-themes
echo "Flushing WordPress cache..."
"$WP_CLI_CMD" cache flush
# Check for Kinsta environment and purge cache if present
if [ -f "/etc/update-motd.d/00-kinsta-welcome" ] || [[ "$current_domain" == *"kinsta"* ]]; then
echo "Kinsta environment detected. Purging Kinsta cache..."
"$WP_CLI_CMD" kinsta cache purge --all
fi
echo ""
echo "✅ Site launch complete! The new domain is '$domain'."
}
# ----------------------------------------------------
# Migrates a site from a backup URL or local file.
# Arguments:
# $1 - The URL/path for the backup file.
# $2 - A flag indicating whether to update URLs.
# ----------------------------------------------------
function migrate_site() {
local backup_url="$1"
local update_urls_flag="$2"
echo "🚀 Starting Site Migration ..."
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! command -v wget &>/dev/null; then echo "❌ Error: wget not found." >&2; return 1; fi
if ! command -v unzip &>/dev/null; then echo "❌ Error: unzip not found." >&2; return 1; fi
if ! command -v tar &>/dev/null; then echo "❌ Error: tar not found." >&2; return 1; fi
local home_directory; home_directory=$(pwd)
local wp_home; wp_home=$( "$WP_CLI_CMD" option get home --skip-themes --skip-plugins )
if [[ "$wp_home" != "http"* ]]; then
echo "❌ Error: WordPress not found in current directory. Migration cancelled." >&2
return 1
fi
# --- Find Private Directory ---
local private_dir
if ! private_dir=$(_get_private_dir); then
# Error message is handled by the helper function.
echo "❌ Error: Can't locate a suitable private folder. Migration cancelled." >&2
return 1
fi
# --- Download and Extract Backup ---
local timedate; timedate=$(date +'%Y-%m-%d-%H%M%S')
local restore_dir="${private_dir}/restore_${timedate}"
mkdir -p "$restore_dir"
cd "$restore_dir" || return 1
local local_file_name; local_file_name=$(basename "$backup_url")
if [ -f "${home_directory}/${local_file_name}" ]; then
mv "${home_directory}/${local_file_name}" "${private_dir}/${local_file_name}"
fi
# Handle special URLs
if [[ "$backup_url" == *"admin-ajax.php"* ]]; then
echo " Backup Buddy URL found, transforming..."
backup_url=${backup_url/wp-admin\/admin-ajax.php?action=pb_backupbuddy_backupbuddy&function=download_archive&backupbuddy_backup=/wp-content\/uploads\/backupbuddy_backups/}
fi
if [[ "$backup_url" == *"dropbox.com"* && "$backup_url" != *"dl=1" ]]; then
echo " Dropbox URL found, adding dl=1..."
backup_url=${backup_url/&dl=0/&dl=1}
fi
# Download or use local file
if [ ! -f "${private_dir}/${local_file_name}" ]; then
echo "Downloading from $backup_url..."
wget -q --show-progress --no-check-certificate --progress=bar:force:noscroll -O "backup_file" "$backup_url"
if [ $? -ne 0 ]; then echo "❌ Error: Download failed."; cd "$home_directory"; return 1; fi
else
echo " Local file '${local_file_name}' found. Using it."
mv "${private_dir}/${local_file_name}" ./backup_file
fi
# Extract based on extension
echo "Extracting backup..."
if [[ "$backup_url" == *".zip"* || "$local_file_name" == *".zip"* ]]; then
unzip -q -o backup_file -x "__MACOSX/*" "cgi-bin/*"
elif [[ "$backup_url" == *".tar.gz"* || "$local_file_name" == *".tar.gz"* ]]; then
tar xzf backup_file
elif [[ "$backup_url" == *".tar"* || "$local_file_name" == *".tar"* ]]; then
tar xf backup_file
else # Assume zip if no extension matches
echo " No clear extension, assuming .zip format."
unzip -q -o backup_file -x "__MACOSX/*" "cgi-bin/*"
fi
rm -f backup_file
# --- Migrate Files ---
local wordpresspath; wordpresspath=$( find . -type d -name 'wp-content' -print -quit )
if [[ -z "$wordpresspath" ]]; then
echo "❌ Error: Can't find wp-content/ in backup. Migration cancelled."; cd "$home_directory"; return 1
fi
echo "Migrating files..."
# Migrate mu-plugins if found
if [ -d "$wordpresspath/wp-content/mu-plugins" ]; then
echo "Moving: mu-plugins"
cd "$wordpresspath/wp-content/mu-plugins"
for working in *; do
echo "$working"
if [ -f "$home_directory/wp-content/mu-plugins/$working" ]; then
rm "$home_directory/wp-content/mu-plugins/$working"
fi
if [ -d "$home_directory/wp-content/mu-plugins/$working" ]; then
rm -rf "$home_directory/wp-content/mu-plugins/$working"
fi
mv "$working" "$home_directory/wp-content/mu-plugins/"
done
cd "${private}/restore_${timedate}"
fi
# Migrate blogs.dir if found
if [ -d "$wordpresspath/blogs.dir" ]; then
echo "Moving: blogs.dir"
rm -rf "$home_directory/wp-content/blogs.dir"
mv "$wordpresspath/blogs.dir" "$home_directory/wp-content/"
fi
# Migrate gallery if found
if [ -d "$wordpresspath/gallery" ]; then
echo "Moving: gallery"
rm -rf "$home_directory/wp-content/gallery"
mv "$wordpresspath/gallery" "$home_directory/wp-content/"
fi
# Migrate ngg if found
if [ -d "$wordpresspath/ngg" ]; then
echo "Moving: ngg"
rm -rf "$home_directory/wp-content/ngg"
mv "$wordpresspath/ngg" "$home_directory/wp-content/"
fi
# Migrate uploads if found
if [ -d "$wordpresspath/uploads" ]; then
echo "Moving: uploads"
rm -rf "$home_directory/wp-content/uploads"
mv "$wordpresspath/uploads" "$home_directory/wp-content/"
fi
# Migrate themes if found
for d in $wordpresspath/themes/*/; do
echo "Moving: themes/$( basename "$d" )"
rm -rf "$home_directory/wp-content/themes/$( basename "$d" )"
mv "$d" "$home_directory/wp-content/themes/"
done
# Migrate plugins if found
for d in $wordpresspath/plugins/*/; do
echo "Moving: plugins/$( basename "$d" )"
rm -rf "$home_directory/wp-content/plugins/$( basename "$d" )"
mv "$d" "$home_directory/wp-content/plugins/"
done
# Find and move non-default root files
local backup_root_dir; backup_root_dir=$(dirname "$wordpresspath")
cd "$backup_root_dir" || return 1
local default_files=( index.php license.txt readme.html wp-activate.php wp-app.php wp-blog-header.php wp-comments-post.php wp-config-sample.php wp-cron.php wp-links-opml.php wp-load.php wp-login.php wp-mail.php wp-pass.php wp-register.php wp-settings.php wp-signup.php wp-trackback.php xmlrpc.php wp-admin wp-config.php wp-content wp-includes )
for item in *; do
is_default=false
for default in "${default_files[@]}"; do
if [[ "$item" == "$default" ]]; then is_default=true; break; fi
done
if ! $is_default; then
echo "Moving root item: $item"
mv -f "$item" "${home_directory}/"
fi
done
cd "$home_directory"
# --- Database Migration ---
local database
if [[ "$(uname)" == "Darwin" ]]; then
# macOS/BSD version using stat -f
database=$(find "$restore_dir" "$home_directory" -type f -name '*.sql' -print0 | xargs -0 stat -f '%m %N' | sort -n | tail -1 | cut -f2- -d" ")
else
# Linux version using find -printf
database=$(find "$restore_dir" "$home_directory" -type f -name '*.sql' -printf '%T@ %p\n' | sort -n | tail -1 | cut -d' ' -f2-)
fi
if [[ -z "$database" || ! -f "$database" ]]; then
echo "⚠️ Warning: No .sql file found in backup. Skipping database import.";
else
echo "Importing database from $database..."
local search_privacy; search_privacy=$( "$WP_CLI_CMD" option get blog_public --skip-plugins --skip-themes )
# Outputs table prefix and updates if different
cd "${restore_dir}/${wordpresspath}/../"
if [ -f wp-config.php ]; then
table_prefix=$( cat wp-config.php | grep table_prefix | perl -n -e '/\047(.+)\047/&& print $1' )
fi
cd "$home_directory"
current_table_prefix=$( wp config get table_prefix --skip-plugins --skip-themes )
if [[ $table_prefix != "" && $table_prefix != "$current_table_prefix" ]]; then
echo "Updating table prefix from $current_table_prefix to $table_prefix"
wp config set table_prefix $table_prefix --skip-plugins --skip-themes
fi
# Reset the database first
"$WP_CLI_CMD" db reset --yes --skip-plugins --skip-themes
# Import using WP-CLI
if ! "$WP_CLI_CMD" db import "$database"; then
echo " Error: Database import failed. Please check the error message above." >&2
cd "$home_directory"
return 1
fi
"$WP_CLI_CMD" cache flush --skip-plugins --skip-themes
"$WP_CLI_CMD" option update blog_public "$search_privacy" --skip-plugins --skip-themes
# URL updates
local wp_home_imported; wp_home_imported=$( "$WP_CLI_CMD" option get home --skip-plugins --skip-themes )
if [[ "$update_urls_flag" == "true" && "$wp_home_imported" != "$wp_home" ]]; then
echo "Updating URLs from $wp_home_imported to $wp_home..."
"$WP_CLI_CMD" search-replace "$wp_home_imported" "$wp_home" --all-tables --report-changed-only --skip-plugins --skip-themes
fi
fi
# --- Cleanup & Final Steps ---
echo "Performing cleanup and final optimizations..."
local plugins_to_remove=( backupbuddy wordfence w3-total-cache wp-super-cache ewww-image-optimizer )
for plugin in "${plugins_to_remove[@]}"; do
if "$WP_CLI_CMD" plugin is-installed "$plugin" --skip-plugins --skip-themes &>/dev/null; then
echo "Removing plugin: $plugin"
"$WP_CLI_CMD" plugin delete "$plugin" --skip-plugins --skip-themes
fi
done
# Convert tables to InnoDB
local alter_queries; alter_queries=$("$WP_CLI_CMD" db query "SELECT CONCAT('ALTER TABLE ', TABLE_SCHEMA,'.', TABLE_NAME, ' ENGINE=InnoDB;') FROM information_schema.TABLES WHERE ENGINE = 'MyISAM' AND TABLE_SCHEMA=DATABASE()" --skip-column-names --skip-plugins --skip-themes)
if [[ -n "$alter_queries" ]]; then
echo "Converting MyISAM tables to InnoDB..."
echo "$alter_queries" | "$WP_CLI_CMD" db query --skip-plugins --skip-themes
fi
"$WP_CLI_CMD" rewrite flush
if "$WP_CLI_CMD" plugin is-active woocommerce --skip-plugins --skip-themes &>/dev/null; then
"$WP_CLI_CMD" wc tool run regenerate_product_attributes_lookup_table --user=1 --skip-plugins --skip-themes
fi
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
# Clean up restore directory
rm -rf "$restore_dir"
echo "✅ Site migration complete!"
}
# ----------------------------------------------------
# Monitors server access logs in real-time.
# ----------------------------------------------------
function monitor_traffic() {
local limit_arg="$1"
local process_from_now="$2"
if ! setup_gum; then
echo "Aborting monitor: gum setup failed." >&2
return 1
fi
# --- Configuration ---
local limit=${limit_arg:-25}
local log="$HOME/logs/access.log"
local initial_lines_to_process=1 # How many lines to look back initially (only used with --now)
# --- End Configuration ---
if [ ! -f "$log" ]; then
echo "Error: Log file not found at $log" >&2
exit 1
fi
# --- Initial Setup ---
local start_line=1 # Default: start from the beginning
if [ "$process_from_now" = true ]; then
echo "Processing from near the end (--now specified)." >&2
local initial_log_count; initial_log_count=$(wc -l < "$log")
local calculated_start=$((initial_log_count - initial_lines_to_process + 1))
if [ $calculated_start -gt 1 ]; then
start_line=$calculated_start
else
start_line=1
fi
else
echo "Processing from the beginning of the log (line 1)." >&2
fi
echo "Starting analysis from line: $start_line | Top hits limit: $limit" >&2
# --- End Initial Setup ---
trap "echo; echo 'Monitoring stopped.'; exit 0" INT
sleep 2 # Give user time to read initial messages
while true; do
local current_log_count; current_log_count=$(wc -l < "$log")
if [ "$current_log_count" -lt "$start_line" ]; then
echo "Warning: Log file appears to have shrunk or rotated. Resetting start line to 1." >&2
start_line=1
sleep 1
current_log_count=$(wc -l < "$log")
if [ "$current_log_count" -lt 1 ]; then
echo "Log file is empty or unreadable after reset. Waiting..." >&2
sleep 5
continue
fi
fi
local actual_lines_processed=$((current_log_count - start_line + 1))
if [ $actual_lines_processed -lt 0 ]; then
actual_lines_processed=0
fi
local overview_header="PHP Workers,Log File,Processed,From Time,To Time\n"
local overview_data=""
local php_workers; php_workers=$(ps -eo pid,uname,comm,%cpu,%mem,time --sort=time --no-headers | grep '[p]hp-fpm' | grep -v 'root' | wc -l)
local first_line_time; first_line_time=$(sed -n "${start_line}p" "$log" | awk -F'[][]' '{print $2}' | head -n 1)
[ -z "$first_line_time" ] && first_line_time="N/A"
local last_line_time; last_line_time=$(tail -n 1 "$log" | awk -F'[][]' '{print $2}' | head -n 1)
[ -z "$last_line_time" ] && last_line_time="N/A"
overview_data+="$php_workers,$log,$actual_lines_processed,$first_line_time,$last_line_time\n"
local output_header="Hits,IP Address,Status Code,Last User Agent\n"
local output_data=""
local top_combinations; top_combinations=$(timeout 10s sed -n "$start_line,\$p" "$log" | \
awk '{print $2 " " $8}' | \
sort | \
uniq -c | \
sort -nr | \
head -n "$limit")
if [ -z "$top_combinations" ]; then
output_data+="0,No new data,-,\"N/A\"\n"
else
while IFS= read -r line; do
line=$(echo "$line" | sed 's/^[ \t]*//;s/[ \t]*$//')
local count ip status_code
read -r count ip status_code <<< "$line"
if ! [[ "$count" =~ ^[0-9]+$ ]] || [[ -z "$ip" ]] || ! [[ "$status_code" =~ ^[0-9]+$ ]]; then
continue
fi
local ip_user_agent; ip_user_agent=$(timeout 2s sed -n "$start_line,\$p" "$log" | grep " $ip " | tail -n 1 | awk -F\" '{print $6}' | cut -c 1-100)
ip_user_agent=${ip_user_agent//,/}
ip_user_agent=${ip_user_agent//\"/}
[ -z "$ip_user_agent" ] && ip_user_agent="-"
output_data+="$count,$ip,$status_code,\"$ip_user_agent\"\n"
done < <(echo -e "$top_combinations")
fi
clear
echo "--- Overview (Lines $start_line - $current_log_count | Total: $actual_lines_processed) ---"
echo -e "$overview_header$overview_data" | "$GUM_CMD" table --print
echo
echo "--- Top $limit IP/Status Hits (Lines $start_line - $current_log_count) ---"
echo -e "$output_header$output_data" | "$GUM_CMD" table --print
sleep 2
done
}
# ----------------------------------------------------
# Monitors access and error logs for HTTP 500 and PHP fatal errors.
# ----------------------------------------------------
function monitor_errors() {
if ! setup_gum; then
echo "Aborting monitor: gum setup failed." >&2
return 1
fi
# --- Find Log Files ---
local access_log_path=""
if [ -f "$HOME/logs/access.log" ]; then
access_log_path="$HOME/logs/access.log"
elif [ -f "logs/access.log" ]; then
access_log_path="logs/access.log"
elif [ -f "../logs/access.log" ]; then
access_log_path="../logs/access.log"
fi
local error_log_path=""
if [ -f "$HOME/logs/error.log" ]; then
error_log_path="$HOME/logs/error.log"
elif [ -f "logs/error.log" ]; then
error_log_path="logs/error.log"
elif [ -f "../logs/error.log" ]; then
error_log_path="../logs/error.log"
fi
local files_to_monitor=()
if [ -n "$access_log_path" ]; then
echo "Checking for 500 errors in: $access_log_path" >&2
files_to_monitor+=("$access_log_path")
fi
if [ -n "$error_log_path" ]; then
echo "Checking for Fatal errors in: $error_log_path" >&2
files_to_monitor+=("$error_log_path")
fi
if [ ${#files_to_monitor[@]} -eq 0 ]; then
echo "No log files found in standard locations (~/logs/, logs/, ../logs/)" >&2
return 1
fi
echo "Streaming errors from specified logs..." >&2
echo "(Press Ctrl+C to stop)" >&2
# --- Real-time Stream using `tail -F` ---
tail -q -n 0 -F "${files_to_monitor[@]}" | while read -r line; do
# Skip empty lines that might come from the pipe
if [ -z "$line" ]; then
continue
fi
# Check for the most specific term first ("Fatal") before less specific terms.
if [[ "$line" == *"Fatal"* ]]; then
"$GUM_CMD" log --level error "$line"
elif [[ "$line" == *" 500 "* ]]; then
"$GUM_CMD" log --level error "$line"
fi
done
}
# ----------------------------------------------------
# Tails the access log for a clean, real-time view.
# ----------------------------------------------------
function monitor_access_log() {
if ! setup_gum; then
echo "Aborting monitor: gum setup failed." >&2
return 1
fi
# --- Find Log File ---
local access_log_path=""
if [ -f "$HOME/logs/access.log" ]; then
access_log_path="$HOME/logs/access.log"
elif [ -f "logs/access.log" ]; then
access_log_path="logs/access.log"
elif [ -f "../logs/access.log" ]; then
access_log_path="../logs/access.log"
fi
if [ -z "$access_log_path" ]; then
echo "No access.log file found in standard locations (~/logs/, logs/, ../logs/)" >&2
return 1
fi
echo "Streaming log: $access_log_path" >&2
echo "(Press Ctrl+C to stop)" >&2
# --- Real-time Stream using `tail -F` ---
tail -n 50 -F "$access_log_path" | while read -r line; do
# Skip empty lines
if [ -z "$line" ]; then
continue
fi
"$GUM_CMD" log --level info "$line"
done
}
# ----------------------------------------------------
# Tails the error log for a clean, real-time view.
# ----------------------------------------------------
function monitor_error_log() {
if ! setup_gum; then
echo "Aborting monitor: gum setup failed." >&2
return 1
fi
# --- Find Log File ---
local error_log_path=""
if [ -f "$HOME/logs/error.log" ]; then
error_log_path="$HOME/logs/error.log"
elif [ -f "logs/error.log" ]; then
error_log_path="logs/error.log"
elif [ -f "../logs/error.log" ]; then
error_log_path="../logs/error.log"
fi
if [ -z "$error_log_path" ]; then
echo "No error.log file found in standard locations (~/logs/, logs/, ../logs/)" >&2
return 1
fi
echo "Streaming log: $error_log_path" >&2
echo "(Press Ctrl+C to stop)" >&2
# --- Real-time Stream using `tail -F` ---
tail -n 50 -F "$error_log_path" | while read -r line; do
# Skip empty lines
if [ -z "$line" ]; then
continue
fi
"$GUM_CMD" log --level error "$line"
done
}
# ----------------------------------------------------
# Reset Commands
# Handles resetting WordPress components or permissions.
# ----------------------------------------------------
# ----------------------------------------------------
# Resets the WordPress installation to a clean, default state.
# ----------------------------------------------------
function reset_wp() {
# This function now only accepts an optional email flag.
# The admin user is selected interactively.
local admin_email="$1"
# --- Pre-flight Checks ---
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
if ! command -v wget &>/dev/null; then echo "❌ Error: wget not found." >&2; return 1; fi
if ! command -v unzip &>/dev/null; then echo "❌ Error: unzip not found." >&2; return 1; fi
if ! command -v curl &>/dev/null; then echo "❌ Error: curl not found." >&2; return 1; fi
if ! setup_gum; then echo "❌ Error: gum is required for the interactive admin picker." >&2; return 1; fi
echo "🚀 Starting WordPress Site Reset 🚀"
# --- Interactively Select Admin User ---
echo "Fetching list of administrators..."
local admin_users
admin_users=$("$WP_CLI_CMD" user list --role=administrator --field=user_login --format=csv)
if [ -z "$admin_users" ]; then
echo "❌ Error: No administrator users found to assign to the new installation." >&2
return 1
fi
local admin_user
admin_user=$(echo "$admin_users" | "$GUM_CMD" choose --header "Select an administrator for the new installation")
if [ -z "$admin_user" ]; then
echo "No administrator selected. Aborting reset."
return 0
fi
echo "✅ Selected administrator: $admin_user"
# --- End Select Admin User ---
echo "This is a destructive operation."
# A 3-second countdown to allow the user to abort (Ctrl+C)
for i in {3..1}; do echo -n "Continuing in $i... "; sleep 1; done; echo
# --- Gather Info Before Reset ---
local url; url=$( "$WP_CLI_CMD" option get home --skip-plugins --skip-themes )
local title; title=$( "$WP_CLI_CMD" option get blogname --skip-plugins --skip-themes )
# If admin_email flag is not supplied, get it from the selected user.
if [ -z "$admin_email" ]; then
admin_email=$("$WP_CLI_CMD" user get "$admin_user" --field=user_email --format=csv)
echo " Admin email not provided. Using email from selected user '$admin_user': $admin_email"
fi
# --- Perform Reset ---
echo "Step 1/9: Resetting the database..."
"$WP_CLI_CMD" db reset --yes --skip-plugins --skip-themes
echo "Step 2/9: Downloading latest WordPress core..."
"$WP_CLI_CMD" core download --force --skip-plugins --skip-themes
echo "Step 3/9: Installing WordPress core..."
"$WP_CLI_CMD" core install --url="$url" --title="$title" --admin_user="$admin_user" --admin_email="$admin_email" --skip-plugins --skip-themes
echo "Step 4/9: Deleting all other themes..."
"$WP_CLI_CMD" theme delete --all --force --skip-plugins --skip-themes
echo "Step 5/9: Deleting all plugins..."
"$WP_CLI_CMD" plugin delete --all --skip-plugins --skip-themes
echo "Step 6/9: Finding the latest default WordPress theme..."
latest_default_theme=$("$WP_CLI_CMD" theme search twenty --field=slug --per-page=1 --quiet --skip-plugins --skip-themes)
if [ $? -ne 0 ] || [ -z "$latest_default_theme" ]; then
echo "❌ Error: Could not determine the latest default theme. Aborting reset."
return 1
fi
echo "✅ Latest default theme is '$latest_default_theme'."
echo "Step 7/9: Installing and activating '$latest_default_theme'..."
"$WP_CLI_CMD" theme install "$latest_default_theme" --force --activate --skip-plugins --skip-themes
echo "Step 8/9: Cleaning up directories (mu-plugins, uploads)..."
rm -rf wp-content/mu-plugins/
mkdir -p wp-content/mu-plugins/
rm -rf wp-content/uploads/
mkdir -p wp-content/uploads/
echo "Step 9/9: Installing helper plugins (Kinsta MU, CaptainCore Helper)..."
# The install_kinsta_mu function will automatically check if it's a Kinsta env.
install_kinsta_mu
install_helper
echo ""
echo "✅ WordPress reset complete!"
echo " URL: $url"
echo " Admin User: $admin_user"
}
# ----------------------------------------------------
# Resets file and folder permissions to common defaults (755 for dirs, 644 for files).
# ----------------------------------------------------
function reset_permissions() {
echo "Resetting file and folder permissions to defaults"
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
echo "✅ Permissions have been reset."
}
# ----------------------------------------------------
# Deactivates a suspend message by removing the mu-plugin.
# ----------------------------------------------------
function suspend_deactivate() {
local wp_content="$1"
# Set default wp-content if not provided
if [[ -z "$wp_content" ]]; then
wp_content="wp-content"
fi
local suspend_file="${wp_content}/mu-plugins/do-suspend.php"
if [ -f "$suspend_file" ]; then
echo "Deactivating suspend message by removing ${suspend_file}..."
rm "$suspend_file"
echo "✅ Suspend message deactivated. Site is now live."
else
echo "Site appears to be already live (suspend file not found)."
fi
# Clear Kinsta cache if environment is detected
if [ -f "/etc/update-motd.d/00-kinsta-welcome" ]; then
if setup_wp_cli && "$WP_CLI_CMD" kinsta cache purge --all --skip-themes &> /dev/null; then
echo "Kinsta cache purged."
else
echo "Warning: Could not purge Kinsta cache. Is the 'wp kinsta' command available?" >&2
fi
fi
}
# ----------------------------------------------------
# Activates a suspend message by adding an mu-plugin.
# ----------------------------------------------------
function suspend_activate() {
local name="$1"
local link="$2"
local wp_content="$3"
# Set default wp-content if not provided
if [[ -z "$wp_content" ]]; then
wp_content="wp-content"
fi
# Check for required arguments
if [[ -z "$name" || -z "$link" ]]; then
echo "Error: Missing required flags for 'suspend activate'." >&2
show_command_help "suspend"
return 1
fi
if [ ! -d "${wp_content}/mu-plugins" ]; then
echo "Creating directory: ${wp_content}/mu-plugins"
mkdir -p "${wp_content}/mu-plugins"
fi
# Remove existing deactivation file if present
if [ -f "${wp_content}/mu-plugins/do-suspend.php" ]; then
echo "Removing existing suspend file..."
rm "${wp_content}/mu-plugins/do-suspend.php"
fi
# Create the deactivation mu-plugin
local output_file="${wp_content}/mu-plugins/do-suspend.php"
echo "Generating suspend file at ${output_file}..."
cat <<EOF > "$output_file"
<?php
/**
* Plugin Name: Website Suspended
* Description: Deactivates the front-end of the website.
*/
function captaincore_template_redirect() {
// Return if in WP Admin or CLI
if ( is_admin() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
return;
}
?>
<html>
<head>
<meta charset="utf-8">
<title>Website Suspended</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css?family=Roboto');
body {
text-align: center;
margin: 10% auto;
padding: 0 15px;
font-family: 'Roboto', sans-serif;
overflow: hidden;
display: block;
max-width: 550px;
background: #eeeeee;
}
p {
margin-top: 3%;
line-height: 1.4em;
display: block;
}
img {
margin-top: 1%;
}
a {
color:#27c3f3;
}
</style>
</head>
<body>
<div class="row">
<div class="col s12">
<div class="card">
<div class="card-content">
<span class="card-title">Website Suspended</span>
<p>This website is currently unavailable.</p>
</div>
<div class="card-content grey lighten-4">
<p>Site owners may contact <a href="${link}" target="_blank" rel="noopener noreferrer">${name}</a>.</p>
</div>
</div>
</div>
</div>
</body>
</html>
<?php
// Stop WordPress from loading further
die();
}
add_action( 'template_redirect', 'captaincore_template_redirect', 1 );
EOF
echo "✅ Generated ${output_file}"
# Clear Kinsta cache if environment is detected
if [ -f "/etc/update-motd.d/00-kinsta-welcome" ]; then
if setup_wp_cli && "$WP_CLI_CMD" kinsta cache purge --all --skip-themes &> /dev/null; then
echo "Kinsta cache purged."
else
echo "Warning: Could not purge Kinsta cache. Is the 'wp kinsta' command available?" >&2
fi
fi
}
# ----------------------------------------------------
# Update Commands
# Handles WordPress core, theme, and plugin updates.
# ----------------------------------------------------
UPDATE_LOGS_DIR=""
UPDATE_LOGS_LIST_FILE=""
# ----------------------------------------------------
# Ensures update directories and lists exist.
# ----------------------------------------------------
function _ensure_update_setup() {
# Exit if already initialized
if [[ -n "$UPDATE_LOGS_DIR" ]]; then return 0; fi
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
# Using the checkpoint base for consistency as updates are linked to checkpoints
local checkpoint_base_dir="${private_dir}/checkpoints"
UPDATE_LOGS_DIR="${checkpoint_base_dir}/updates"
UPDATE_LOGS_LIST_FILE="$UPDATE_LOGS_DIR/list.json"
mkdir -p "$UPDATE_LOGS_DIR"
if [ ! -f "$UPDATE_LOGS_LIST_FILE" ]; then
echo "[]" > "$UPDATE_LOGS_LIST_FILE"
fi
}
# ----------------------------------------------------
# (Helper) Lets the user select an update log from the list.
# ----------------------------------------------------
function _select_update_log() {
_ensure_update_setup
if ! setup_wp_cli; then return 1; fi
if [ ! -s "$UPDATE_LOGS_LIST_FILE" ]; then
echo " No update logs found. Run '_do update all' to create one." >&2
return 1
fi
# Use PHP to read the detailed list and check format
local php_script_read_list='
<?php
$list_file = "%s";
$list = json_decode(file_get_contents($list_file), true);
if (!is_array($list) || empty($list)) {
echo "EMPTY";
return;
}
// Check if the first item has the new detailed format.
if (!isset($list[0]["formatted_timestamp"])) {
echo "NEEDS_GENERATE";
return;
}
foreach($list as $item) {
if (isset($item["formatted_timestamp"]) && isset($item["hash_before"]) && isset($item["hash_after"]) && isset($item["counts_str"]) && isset($item["diff_stats"])) {
// Output format: formatted_timestamp|hash_before|hash_after|counts_str|diff_stats
echo $item["formatted_timestamp"] . "|" . $item["hash_before"] . "|" . $item["hash_after"] . "|" . $item["counts_str"] . "|" . $item["diff_stats"] . "\n";
}
}
'
local php_script; php_script=$(printf "$php_script_read_list" "$UPDATE_LOGS_LIST_FILE")
local update_entries; update_entries=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [[ "$update_entries" == "EMPTY" ]]; then
echo " No update logs available to select." >&2; return 1;
elif [[ "$update_entries" == "NEEDS_GENERATE" ]]; then
echo "⚠️ The update log list needs to be generated for faster display." >&2
echo "Please run: _do update list-generate" >&2
return 1
fi
local display_items=()
local data_items=()
while IFS='|' read -r formatted_timestamp hash_before hash_after counts_str diff_stats; do
hash_before=$(echo "$hash_before" | tr -d '[:space:]')
hash_after=$(echo "$hash_after" | tr -d '[:space:]')
if [ -z "$hash_before" ] || [ -z "$hash_after" ]; then continue; fi
local display_string
display_string=$(printf "%-28s | %s -> %s | %-18s | %s" \
"$formatted_timestamp" "${hash_before:0:7}" "${hash_after:0:7}" "$counts_str" "$diff_stats")
display_items+=("$display_string")
data_items+=("$hash_before|$hash_after")
done <<< "$update_entries"
if [ ${#display_items[@]} -eq 0 ]; then
echo "❌ No valid update entries to display." >&2
return 1
fi
local prompt_text="${1:-Select an update to inspect}"
local selected_display
selected_display=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --height=20 --prompt="👇 $prompt_text" --indicator="→" --placeholder="")
if [ -z "$selected_display" ]; then
echo "" # Return empty for cancellation
return 0
fi
local selected_index=-1
for i in "${!display_items[@]}"; do
if [[ "${display_items[$i]}" == "$selected_display" ]]; then
selected_index=$i
break
fi
done
if [ "$selected_index" -ne -1 ]; then
echo "${data_items[$selected_index]}"
return 0
else
echo "❌ Error: Could not find selected update." >&2
return 1
fi
}
# ----------------------------------------------------
# Generates a detailed list of updates for faster access.
# ----------------------------------------------------
function update_list_generate() {
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
_ensure_checkpoint_setup # This sets up repo path as well
_ensure_update_setup
local php_script_read_list='
<?php
$list_file = "%s";
if (!file_exists($list_file)) { return; }
$list = json_decode(file_get_contents($list_file), true);
if (!is_array($list) || empty($list)) { return; }
foreach($list as $item) {
// Read from both simple and potentially detailed formats
$timestamp = $item["timestamp"] ?? "N/A";
$hash_before = $item["before"] ?? $item["hash_before"] ?? null;
$hash_after = $item["after"] ?? $item["hash_after"] ?? null;
if ($timestamp && $hash_before && $hash_after) {
echo "$timestamp|$hash_before|$hash_after\n";
}
}
'
local php_script; php_script=$(printf "$php_script_read_list" "$UPDATE_LOGS_LIST_FILE")
local update_entries; update_entries=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [ -z "$update_entries" ]; then
echo " No update logs found to generate a list from."
return 0
fi
echo "🔎 Generating detailed update list... (This may take a moment)"
local detailed_items=()
while IFS='|' read -r timestamp hash_before hash_after; do
hash_before=$(echo "$hash_before" | tr -d '[:space:]')
hash_after=$(echo "$hash_after" | tr -d '[:space:]')
if [ -z "$hash_before" ] || [ -z "$hash_after" ]; then continue; fi
# Validate that both commits exist before proceeding
if ! "$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" cat-file -e "${hash_before}^{commit}" &>/dev/null; then
echo "⚠️ Warning: Could not find 'before' commit '$hash_before'. Skipping entry." >&2
continue
fi
if ! "$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" cat-file -e "${hash_after}^{commit}" &>/dev/null; then
echo "⚠️ Warning: Could not find 'after' commit '$hash_after'. Skipping entry." >&2
continue
fi
local manifest_after; manifest_after=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" show "$hash_after:manifest.json" 2>/dev/null)
if [ -z "$manifest_after" ]; then
echo "⚠️ Warning: Could not find manifest for 'after' hash '$hash_after'. Skipping entry." >&2
continue
fi
local php_get_counts='
<?php
$manifest_json = <<<'EOT'
%s
EOT;
$data = json_decode($manifest_json, true);
$theme_count = isset($data["themes"]) && is_array($data["themes"]) ? count($data["themes"]) : 0;
$plugin_count = isset($data["plugins"]) && is_array($data["plugins"]) ? count($data["plugins"]) : 0;
echo "$theme_count Themes, $plugin_count Plugins";
'
local counts_script; counts_script=$(printf "$php_get_counts" "$manifest_after")
local counts_str; counts_str=$(echo "$counts_script" | "$WP_CLI_CMD" eval-file -)
local diff_stats
diff_stats=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --shortstat "$hash_before" "$hash_after" -- 'plugins/' 'themes/' 'mu-plugins/' | sed 's/^[ \t]*//')
if [ -z "$diff_stats" ]; then diff_stats="No file changes"; fi
local formatted_timestamp
if [[ "$(uname)" == "Darwin" ]]; then
formatted_timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" -u "$timestamp" "+%a, %b %d, %Y, %-I:%M %p")
else
formatted_timestamp=$(date -d "$timestamp" "+%a, %b %d, %Y, %-I:%M %p")
fi
local json_item
json_item=$(printf '{"hash_before": "%s", "hash_after": "%s", "timestamp": "%s", "formatted_timestamp": "%s", "counts_str": "%s", "diff_stats": "%s"}' \
"$hash_before" "$hash_after" "$timestamp" "$formatted_timestamp" "$counts_str" "$diff_stats")
detailed_items+=("$json_item")
done <<< "$update_entries"
local full_json="["
full_json+=$(IFS=,; echo "${detailed_items[*]}")
full_json+="]"
local php_write_script='
<?php
$list_file = "%s";
$json_data = <<<'EOT'
%s
EOT;
$data = json_decode($json_data, true);
file_put_contents($list_file, json_encode($data, JSON_PRETTY_PRINT));
'
local write_script; write_script=$(printf "$php_write_script" "$UPDATE_LOGS_LIST_FILE" "$full_json")
if echo "$write_script" | "$WP_CLI_CMD" eval-file -; then
echo "✅ Detailed update list saved to $UPDATE_LOGS_LIST_FILE"
else
echo "❌ Error: Failed to write detailed update list."
fi
}
# ----------------------------------------------------
# Lists all past updates from the pre-generated list.
# ----------------------------------------------------
function update_list() {
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
_ensure_checkpoint_setup # Ensures repo path is set
local selected_hashes
selected_hashes=$(_select_update_log "Select an update to inspect")
if [ -z "$selected_hashes" ]; then
echo "No update selected."
return 0
fi
if [ $? -ne 0 ]; then
return 1 # Error already printed by helper
fi
local selected_hash_before; selected_hash_before=$(echo "$selected_hashes" | cut -d'|' -f1)
local selected_hash_after; selected_hash_after=$(echo "$selected_hashes" | cut -d'|' -f2)
checkpoint_show "$selected_hash_after" "$selected_hash_before"
}
# ----------------------------------------------------
# Runs the full update process.
# ----------------------------------------------------
function run_update_all() {
if ! setup_git; then return 1; fi
if ! command -v wp &>/dev/null; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
_ensure_checkpoint_setup
_ensure_update_setup
echo "🚀 Starting full WordPress update process..."
echo " - Step 1/5: Creating 'before' checkpoint..."
checkpoint_create > /dev/null
local hash_before; hash_before=$(checkpoint_latest)
if [ -z "$hash_before" ]; then
echo "❌ Error: Could not create 'before' checkpoint." >&2
return 1
fi
echo " Before Hash: $hash_before"
echo " - Step 2/5: Running WordPress updates..."
echo " - Updating core..."
"$WP_CLI_CMD" core update --skip-plugins --skip-themes
echo " - Updating themes..."
"$WP_CLI_CMD" theme update --all --skip-plugins --skip-themes
echo " - Updating plugins..."
"$WP_CLI_CMD" plugin update --all --skip-plugins --skip-themes
echo " - Step 3/5: Creating 'after' checkpoint..."
checkpoint_create > /dev/null
local hash_after; hash_after=$(checkpoint_latest)
if [ -z "$hash_after" ]; then
echo "❌ Error: Could not create 'after' checkpoint." >&2
return 1
fi
echo " After Hash: $hash_after"
if [ "$hash_before" == "$hash_after" ]; then
echo "✅ No updates were available. Site is up-to-date."
return 0
fi
echo " - Step 4/5: Generating update log entry..."
local timestamp; timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# This creates a simple entry. `update list-generate` will enrich it later.
local php_list_template='
<?php
$list_file = "%s";
$before = "%s";
$after = "%s";
$timestamp = "%s";
$list = file_exists($list_file) ? json_decode(file_get_contents($list_file), true) : [];
if (!is_array($list)) { $list = []; }
$new_entry = ["before" => $before, "after" => $after, "timestamp" => $timestamp];
// To keep it simple, we just read the simple format and add to it.
// The list-generate command is responsible for creating the detailed format.
$simple_list = [];
foreach($list as $item) {
$simple_list[] = [
"before" => $item["before"] ?? $item["hash_before"] ?? null,
"after" => $item["after"] ?? $item["hash_after"] ?? null,
"timestamp" => $item["timestamp"] ?? null
];
}
array_unshift($simple_list, $new_entry);
echo json_encode($simple_list, JSON_PRETTY_PRINT);
'
local php_list_script; php_list_script=$(printf "$php_list_template" "$UPDATE_LOGS_LIST_FILE" "$hash_before" "$hash_after" "$timestamp")
local temp_list_file; temp_list_file=$(mktemp)
if echo "$php_list_script" | "$WP_CLI_CMD" eval-file - > "$temp_list_file"; then
mv "$temp_list_file" "$UPDATE_LOGS_LIST_FILE"
else
echo "❌ Error: Failed to update master update list." >&2
rm "$temp_list_file"
fi
echo " - Step 5/5: Regenerating detailed update list..."
update_list_generate > /dev/null
echo "✅ Update process complete."
}
# ----------------------------------------------------
# Upgrades the _do script to the latest version.
# ----------------------------------------------------
function run_upgrade() {
echo "🚀 Checking for the latest version of _do..."
# --- Pre-flight Checks ---
if ! command -v curl &> /dev/null; then echo "❌ Error: curl is required for upgrades." >&2; return 1; fi
if ! command -v grep &> /dev/null; then echo "❌ Error: grep is required for upgrades." >&2; return 1; fi
if ! command -v realpath &> /dev/null; then echo "❌ Error: realpath is required for upgrades." >&2; return 1; fi
if ! setup_gum; then return 1; fi
# --- Download latest version to a temporary file ---
local upgrade_url="https://github.com/CaptainCore/do/releases/latest/download/_do.sh"
local temp_file
temp_file=$(mktemp)
if ! curl -sL "$upgrade_url" -o "$temp_file"; then
echo "❌ Error: Failed to download the latest version from $upgrade_url" >&2
rm -f "$temp_file"
return 1
fi
# --- Extract version numbers ---
local new_version
new_version=$(grep 'CAPTAINCORE_DO_VERSION=' "$temp_file" | head -n1 | cut -d'"' -f2)
if [ -z "$new_version" ]; then
echo "❌ Error: Could not determine the version number from the downloaded file." >&2
rm -f "$temp_file"
return 1
fi
local current_version="$CAPTAINCORE_DO_VERSION"
echo " - Current version: $current_version"
echo " - Latest version: $new_version"
# --- Determine install path & type ---
local install_path
local current_script_path
local is_system_install=false
# Try to determine the path of the running script
current_script_path=$(realpath "$0" 2>/dev/null)
# Check if the script is running from a common system binary path
if [[ -n "$current_script_path" && -f "$current_script_path" ]]; then
if [[ "$current_script_path" == /usr/local/bin/* || "$current_script_path" == /usr/bin/* || "$current_script_path" == /bin/* ]]; then
is_system_install=true
fi
fi
# --- Handle Different Scenarios ---
if [[ "$is_system_install" == "true" ]]; then
# --- UPGRADE SCENARIO for an existing system install ---
echo " - Found existing system installation at: $current_script_path"
install_path="$current_script_path"
local latest_available
latest_available=$(printf '%s\n' "$new_version" "$current_version" | sort -V | tail -n1)
if [[ "$new_version" == "$current_version" ]]; then
echo "✅ You are already using the latest version ($current_version)."
if ! "$GUM_CMD" confirm "Do you want to reinstall it anyway?"; then
rm -f "$temp_file"
return 0
fi
elif [[ "$latest_available" == "$current_version" ]]; then
echo "✅ You are running a newer version ($current_version) than the latest release ($new_version). No action taken."
rm -f "$temp_file"
return 0
fi
echo " - Upgrading to version $new_version..."
else
# --- NEW INSTALL SCENARIO (for dev scripts or curl|bash) ---
if [[ -n "$current_script_path" && -f "$current_script_path" ]]; then
echo " - Running from a local script. Treating as a new system-wide installation."
else
echo " - No physical script found. Treating as a new system-wide installation."
fi
install_path="/usr/local/bin/_do"
echo " - Target install location: $install_path"
# If the target already exists, check its version to avoid unnecessary work
if [ -f "$install_path" ]; then
local existing_install_version
existing_install_version=$(grep 'CAPTAINCORE_DO_VERSION=' "$install_path" | head -n1 | cut -d'"' -f2)
if [[ "$new_version" == "$existing_install_version" ]]; then
echo "✅ The latest version ($new_version) is already installed at $install_path. No action taken."
rm -f "$temp_file"
return 0
fi
fi
fi
# --- Perform the installation/upgrade ---
echo " - Installing to $install_path..."
# Make the downloaded script executable
chmod +x "$temp_file"
# Check for write permissions and use sudo if needed
if [ -w "$(dirname "$install_path")" ]; then
if ! mv "$temp_file" "$install_path"; then
echo "❌ Error: Failed to move the new version to $install_path." >&2
rm -f "$temp_file" # Clean up temp file on failure
return 1
fi
else
echo " - Write permission is required for the directory $(dirname "$install_path")."
echo " - You may be prompted for your password to complete the installation."
if ! sudo mv "$temp_file" "$install_path"; then
echo "❌ Error: sudo command failed. Could not complete installation/upgrade." >&2
rm -f "$temp_file" # Clean up temp file on failure
return 1
fi
fi
echo "✅ Success! _do version $new_version is now installed at $install_path."
}
# ----------------------------------------------------
# Performs a full site backup to a Restic repository on B2.
# Credentials can be injected via environment variables or piped via stdin.
# ----------------------------------------------------
function vault_create() {
echo "🚀 Starting secure snapshot to Restic B2 repository..."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_wp_cli; then return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then
echo "❌ Error: This does not appear to be a WordPress installation." >&2
return 1
fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- Rclone Cache Size Check ---
if [ -n "$EMAIL_NOTIFY" ]; then
local rclone_cache_dir="$HOME/.cache/rclone"
if [ -d "$rclone_cache_dir" ]; then
local cache_size_bytes
cache_size_bytes=$(du -sb "$rclone_cache_dir" | awk '{print $1}')
local size_limit_bytes=10737418240 # 10 GB
if (( cache_size_bytes > size_limit_bytes )); then
local site_url
site_url=$("$WP_CLI_CMD" option get home)
local cache_size_gb
cache_size_gb=$(awk -v bytes="$cache_size_bytes" 'BEGIN { printf "%.2f", bytes / 1024 / 1024 / 1024 }')
local email_subject="Rclone Cache Warning for ${site_url}"
local email_message="Warning: The rclone cache folder at ${rclone_cache_dir} is larger than 10GB. Current size: ${cache_size_gb}GB. This might cause issues with 'vault create' operations."
echo " - ⚠️ Rclone cache is large (${cache_size_gb}GB). Sending email notification to $EMAIL_NOTIFY..."
"$WP_CLI_CMD" eval "wp_mail( '$EMAIL_NOTIFY', '$email_subject', '$email_message', ['Content-Type: text/html; charset=UTF-8'] );"
fi
fi
fi
# --- Check/Initialize Restic Repository ---
echo " - Checking for repository at ${RESTIC_REPOSITORY}..."
if ! "$RESTIC_CMD" stats > /dev/null 2>&1; then
echo " - Repository not found or is invalid. Attempting to initialize..."
if ! "$RESTIC_CMD" init; then
echo "❌ Error: Failed to initialize Restic repository." >&2
return 1
fi
echo " - ✅ Repository initialized successfully."
else
echo " - ✅ Repository found."
fi
# --- Create DB Dump in Private Directory ---
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
local sql_file_path="${private_dir}/database-backup.sql"
echo " - Generating database dump at: ${sql_file_path}"
if ! "$WP_CLI_CMD" db export "$sql_file_path" --add-drop-table --single-transaction --quick --max_allowed_packet=512M > /dev/null; then
echo "❌ Error: Database export failed." >&2
return 1
fi
# --- Move DB dump to root for snapshot ---
local wp_root_dir
wp_root_dir=$(realpath ".")
local temporary_sql_path_in_root="${wp_root_dir}/database-backup.sql"
echo " - Temporarily moving database dump to web root for snapshotting."
if ! mv "$sql_file_path" "$temporary_sql_path_in_root"; then
echo "❌ Error: Could not move database dump to web root." >&2
return 1
fi
# --- Run Restic Backup ---
local original_dir
original_dir=$(pwd)
echo " - Changing to WordPress root ($wp_root_dir) for clean snapshot paths..."
cd "$wp_root_dir" || {
echo "❌ Error: Could not change to WordPress root directory." >&2
echo " - Attempting to move database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
return 1
}
local human_readable_size
human_readable_size=$(du -sh . | awk '{print $1}')
local tag_args=()
if [[ -n "$human_readable_size" ]]; then
tag_args+=(--tag "size:${human_readable_size}")
fi
echo " - Backing up current directory (.), which now includes the SQL dump..."
if ! "$RESTIC_CMD" backup "." \
"${tag_args[@]}" \
--exclude '**.DS_Store' \
--exclude '*timthumb.txt' \
--exclude 'debug.log' \
--exclude 'error_log' \
--exclude 'phperror_log' \
--exclude 'wp-content/updraft' \
--exclude 'wp-content/cache' \
--exclude 'wp-content/et-cache' \
--exclude 'wp-content/.wps-slots' \
--exclude 'wp-content/wflogs' \
--exclude 'wp-content/uploads/sessions' \
--exclude 'wp-snapshots'; then
echo "❌ Error: Restic backup command failed." >&2
cd "$original_dir"
echo " - Moving database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
return 1
fi
# --- Cleanup: Move DB dump back to private directory ---
cd "$original_dir"
echo " - Moving database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
# Unset variables for security
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Vault snapshot complete!"
}
# ----------------------------------------------------
# (Helper) Reads credentials and sets up the restic environment.
# ----------------------------------------------------
function _setup_vault_env() {
# --- Read Credentials ---
local b2_bucket b2_path b2_key_id b2_app_key restic_password
# Prioritize stdin: If data is being piped in, read from it.
if ! [ -t 0 ]; then
read -r stdin_b2_bucket
read -r stdin_b2_path
read -r stdin_b2_key_id
read -r stdin_b2_app_key
read -r stdin_restic_password
b2_bucket=${stdin_b2_bucket}
b2_path=${stdin_b2_path}
b2_key_id=${stdin_b2_key_id}
b2_app_key=${stdin_b2_app_key}
restic_password=${stdin_restic_password}
fi
# If stdin empty or incomplete, then attempt to load from environment variables.
if [ -z "$b2_bucket" ] || [ -z "$b2_path" ] || [ -z "$b2_key_id" ] || [ -z "$b2_app_key" ] || [ -z "$restic_password" ]; then
b2_bucket="$B2_BUCKET"
b2_path="$B2_PATH"
b2_key_id="$B2_ACCOUNT_ID"
b2_app_key="$B2_ACCOUNT_KEY"
restic_password="$RESTIC_PASSWORD"
fi
if [ -z "$b2_bucket" ] || [ -z "$b2_path" ] || [ -z "$b2_key_id" ] || [ -z "$b2_app_key" ] || [ -z "$restic_password" ]; then
echo "❌ Error: One or more required credentials were not provided or were empty." >&2
return 1
fi
export B2_ACCOUNT_ID="$b2_key_id"
export B2_ACCOUNT_KEY="$b2_app_key"
export RESTIC_PASSWORD="$restic_password"
export RESTIC_REPOSITORY="b2:${b2_bucket}:${b2_path}"
return 0
}
# ----------------------------------------------------
# (Helper) Caches the file list for a snapshot.
# ----------------------------------------------------
function _cache_snapshot_files() {
local snapshot_id="$1"
local cache_file="$2"
# Show a spinner while caching the entire file list.
if ! "$GUM_CMD" spin --spinner dot --title "Caching file list for snapshot ${snapshot_id}..." -- \
"$RESTIC_CMD" ls --json --long --recursive "${snapshot_id}" > "$cache_file";
then
echo "❌ Error: Could not cache the file list for snapshot ${snapshot_id}."
>&2
rm -f "$cache_file" # Clean up partial cache file
return 1
fi
return 0
}
# ----------------------------------------------------
# (Helper) Provides an interactive menu for a selected file.
# ----------------------------------------------------
function _file_action_menu() {
local snapshot_id="$1"
local file_path="$2"
local choice
choice=$("$GUM_CMD" choose "View Content" "Download File" "Restore File" "Back")
case "$choice" in
"View Content")
echo "📄 Viewing content of '$file_path'... (Press 'q' to quit)"
local temp_file
temp_file=$(mktemp)
if [ -z "$temp_file" ];
then
echo "❌ Error: Could not create a temporary file."
>&2
sleep 2
return
fi
# Dump the file content to the temporary file
if ! "$RESTIC_CMD" dump "${snapshot_id}" "${file_path}" > "$temp_file"; then
echo "❌ Error: Could not dump file content from repository."
>&2
rm -f "$temp_file"
sleep 2
return
fi
# View the temporary file with less, ensuring it's interactive
less -RN "$temp_file" </dev/tty
# Clean up the temporary file
rm -f "$temp_file"
;;
"Download File")
local filename
filename=$(basename "$file_path")
echo "⬇️ Downloading '$filename' to current directory..."
if "$GUM_CMD" confirm "Download '${filename}' to '$(pwd)'?";
then
if "$RESTIC_CMD" dump "${snapshot_id}" "${file_path}" > "$filename";
then
local size
size=$(ls -lh "$filename" | awk '{print $5}')
echo "✅ File downloaded: $filename ($size)"
else
echo "❌ Error: Failed to download file."
rm -f "$filename"
fi
else
echo "Download cancelled."
fi
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
;;
"Restore File")
echo "🔄 Restoring '$file_path' to current directory..."
if "$GUM_CMD" confirm "Restore '${file_path}' to '$(pwd)'?";
then
if "$RESTIC_CMD" restore "${snapshot_id}" --include "${file_path}" --target ".";
then
echo "✅ File restored successfully."
else
echo "❌ Error: Failed to restore file."
fi
else
echo "Restore cancelled."
fi
# Add a pause so the user can see the result before returning to the browser.
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
;;
"Back")
return
;;
*)
esac
}
# ----------------------------------------------------
# (Helper) Handles the download/restore of a full folder.
# ----------------------------------------------------
function _download_folder_action() {
local snapshot_id="$1"
local folder_path_in_repo="$2"
echo "📦 Preparing to download folder: '${folder_path_in_repo}'"
echo "This action will restore the selected folder and its full directory structure from the snapshot's root into your current working directory."
echo "For example, restoring '/wp-content/plugins/' will create the path './wp-content/plugins/' here."
if "$GUM_CMD" confirm "Proceed with restoring '${folder_path_in_repo}' to '$(pwd)'?"; then
echo " - Restoring files..."
if "$RESTIC_CMD" restore "${snapshot_id}" --include "${folder_path_in_repo}" --target "."; then
echo "✅ Folder and its contents restored successfully."
else
echo "❌ Error: Failed to restore folder."
fi
else
echo "Download cancelled."
fi
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
}
# ----------------------------------------------------
# (Helper) Provides an interactive file browser for a snapshot.
# ----------------------------------------------------
function _browse_snapshot() {
local snapshot_id="$1"
local cache_file="$2"
local current_path="/"
while true;
do
clear
echo "🗂 Browse Snapshot: ${snapshot_id} | Path: ${current_path}"
# PHP script to parse the cached JSON and format it for the current directory
local php_parser_code='
<?php
$cache_file = $argv[1] ?? "";
$current_path = $argv[2] ?? "/";
if (!file_exists($cache_file)) {
fwrite(STDERR, "Cache file not found.\n");
exit(1);
}
$file_content = file_get_contents($cache_file);
$lines = explode("\n", trim($file_content));
$items_in_current_dir = [];
$dirs_in_current_dir = [];
foreach ($lines as $line) {
if (empty($line)) continue;
$item = json_decode($line, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($item["path"])) continue;
$item_path = $item["path"];
if (strpos($item_path, $current_path) !== 0) continue;
$relative_path = substr($item_path, strlen($current_path));
if (empty($relative_path)) continue;
if (strpos($relative_path, "/") === false) {
if ($item["type"] === "dir") {
$dirs_in_current_dir[$relative_path] = true;
} else {
$items_in_current_dir[$relative_path] = $item;
}
} else {
$dir_name = explode("/", $relative_path)[0];
$dirs_in_current_dir[$dir_name] = true;
}
}
ksort($dirs_in_current_dir);
ksort($items_in_current_dir);
if ($current_path !== "/") {
echo "⤴️ ../ (Up one level)|up|..\n";
}
foreach (array_keys($dirs_in_current_dir) as $dir) {
echo "📁 " . $dir . "/|dir|" . $dir . "\n";
}
foreach ($items_in_current_dir as $name => $item) {
$size_bytes = $item["size"] ?? 0;
$size_formatted = "0 B";
if ($size_bytes >= 1048576) { $size_formatted = round($size_bytes / 1048576, 2) . " MB"; }
elseif ($size_bytes >= 1024) { $size_formatted = round($size_bytes / 1024, 2) . " KB"; }
elseif ($size_bytes > 0) { $size_formatted = $size_bytes . " B"; }
echo "📄 " . $name . " (" . $size_formatted . ")|file|" . $name . "\n";
}
echo "\n";
echo "💾 Download this directory ({$current_path})|download_dir|.\n";
'
# Execute PHP script to get a formatted list from the cache
local temp_script_file;
temp_script_file=$(mktemp)
echo "$php_parser_code" > "$temp_script_file"
local formatted_list;
formatted_list=$(php -f "$temp_script_file" "$cache_file" "$current_path")
rm "$temp_script_file"
# --- Display and selection logic ---
local display_items=()
local data_items=()
while IFS='|'
read -r display_part type_part name_part; do
if [ -z "$display_part" ];
then continue; fi
display_items+=("$display_part")
data_items+=("${type_part}|${name_part}")
done <<< "$formatted_list"
local selected_display
# Use a for loop to pipe items to gum filter, avoiding argument list limits.
selected_display=$(
for item in "${display_items[@]}"; do
echo "$item"
done | "$GUM_CMD" filter --height=20 --prompt="👇 Select a snapshot to browse" --indicator="→" --placeholder=""
)
if [ -z "$selected_display" ];
then
return # Exit the browser
fi
local selected_index=-1
for i in "${!display_items[@]}";
do
if [[ "${display_items[$i]}" == "$selected_display" ]];
then
selected_index=$i
break
fi
done
if [ "$selected_index" -eq -1 ];
then continue; fi
local selected_data="${data_items[selected_index]}"
local item_type;
item_type=$(echo "$selected_data" | cut -d'|' -f1)
local item_name;
item_name=$(echo "$selected_data" | cut -d'|' -f2)
case "$item_type" in
"dir")
current_path="${current_path}${item_name}/"
;;
"file")
_file_action_menu "${snapshot_id}" "${current_path}${item_name}"
;;
"up")
if [[ "$current_path" != "/" ]]; then
# Get parent directory
parent_path=$(dirname "${current_path%/}")
# If the parent is the root, the new path is simply "/".
# Otherwise, it's the parent path with a trailing slash.
if [[ "$parent_path" == "/" ]]; then
current_path="/"
else
current_path="${parent_path}/"
fi
fi
;;
"download_dir")
_download_folder_action "${snapshot_id}" "${current_path}"
;;
esac
done
}
# ----------------------------------------------------
# Lists all snapshots in the Restic repository.
# ----------------------------------------------------
function vault_snapshots() {
local output_mode="$1"
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
if ! command -v php &>/dev/null; then echo "❌ Error: The 'php' command is required for this operation." >&2; return 1; fi
if ! command -v mktemp &>/dev/null; then echo "❌ Error: The 'mktemp' command is required for this operation." >&2; return 1; fi
if ! command -v wc &>/dev/null; then echo "❌ Error: The 'wc' command is required for this operation." >&2; return 1; fi
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching snapshots from repository..." -- \
"$RESTIC_CMD" snapshots --json
)
if [[ ! "$snapshots_json" =~ ^\[ ]]; then
echo "Error: Failed to fetch snapshots. Restic output:" >&2
echo "$snapshots_json" >&2
exit 1
fi
local total_count
read -r -d '' php_script << 'EOF'
$json_string = file_get_contents("php://stdin");
$snapshots = json_decode($json_string, true);
if (json_last_error() === JSON_ERROR_NONE) {
echo count($snapshots);
} else {
echo 0;
}
EOF
total_count=$("$GUM_CMD" spin --spinner dot --title "Counting total snapshots..." -- \
bash -c 'php -r "$1"' _ "$php_script" <<< "$snapshots_json"
)
echo "🔎 Fetching ${total_count} snapshots..."
if [[ "$snapshots_json" == "[]" ]]; then
echo " No snapshots found in the repository."
return 0
fi
local php_parser_code='
<?php
if (defined("STDIN")) {
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
fwrite(STDERR, "PHP Error: Failed to decode JSON - " . json_last_error_msg() . "\n");
exit(1);
}
if (empty($snapshots) || !is_array($snapshots)) {
exit(0);
}
usort($snapshots, function($a, $b) { return strtotime($b["time"]) - strtotime($a["time"]); });
foreach ($snapshots as $snap) {
$size_formatted = "N/A";
if (isset($snap["summary"]["total_bytes_processed"])) {
$size_bytes = (float)$snap["summary"]["total_bytes_processed"];
if ($size_bytes >= 1073741824) {
$size_formatted = round($size_bytes / 1073741824, 2) . " GB";
} elseif ($size_bytes >= 1048576) {
$size_formatted = round($size_bytes / 1048576, 2) . " MB";
} elseif ($size_bytes >= 1024) {
$size_formatted = round($size_bytes / 1024, 2) . " KB";
} elseif ($size_bytes > 0) {
$size_formatted = $size_bytes . " B";
}
}
echo $snap["short_id"] . "|" .
(new DateTime($snap["time"]))->format("Y-m-d H:i:s") . "|" .
$size_formatted . "\n";
}
}
'
local php_script_file
php_script_file=$(mktemp)
if [ -z "$php_script_file" ]; then
echo "❌ Error: Could not create a temporary file for the PHP script." >&2
return 1
fi
echo "$php_parser_code" > "$php_script_file"
local snapshot_list
snapshot_list=$(printf "%s" "$snapshots_json" | php -f "$php_script_file" 2>/dev/null)
rm -f "$php_script_file"
if [ -z "$snapshot_list" ]; then
echo "❌ Error parsing snapshot list." >&2
return 1
fi
local display_items=()
local data_items=()
while IFS='|' read -r id time size; do
if [ -z "$id" ]; then
continue
fi
display_items+=("$(printf "%-9s | %-20s | %-10s" "$id" "$time" "$size")")
data_items+=("$id")
done <<< "$snapshot_list"
if [[ "$output_mode" == "true" ]]; then
printf "%s\n" "${display_items[@]}"
return 0
fi
local selected_display
selected_display=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --height=20 --prompt="👇 Select a snapshot to browse" --indicator="→" --placeholder="")
if [ -z "$selected_display" ]; then echo "No snapshot selected."; return 0; fi
local selected_id
selected_id=$(echo "$selected_display" | awk '{print $1}')
local cache_file;
cache_file=$(mktemp)
if ! _cache_snapshot_files "$selected_id" "$cache_file"; then
[ -f "$cache_file" ] && rm -f "$cache_file"
return 1
fi
_browse_snapshot "$selected_id" "$cache_file"
rm -f "$cache_file"
}
# ----------------------------------------------------
# Mounts the Restic repository to a local directory.
# ----------------------------------------------------
function vault_mount() {
echo "🚀 Preparing to mount Restic repository..."
if ! setup_restic;
then return 1; fi
if ! _setup_vault_env; then return 1;
fi
local mount_point="/tmp/restic_mount_$(date +%s)"
mkdir -p "$mount_point"
echo " - Mount point created at: $mount_point"
echo " - To unmount, run: umount \"$mount_point\""
echo " - Press Ctrl+C to stop the foreground mount process."
"$RESTIC_CMD" mount "$mount_point"
}
# ----------------------------------------------------
# Displays statistics about the Restic repository.
# ----------------------------------------------------
function vault_info() {
echo "🔎 Gathering repository information..."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
if ! setup_rclone; then return 1; fi
if ! command -v php &>/dev/null; then echo "❌ Error: The 'php' command is required for this operation." >&2; return 1; fi
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- Get repo size and file count using rclone ---
local temp_repo_string="${RESTIC_REPOSITORY#b2:}"
local b2_bucket="${temp_repo_string%%:*}"
local b2_path="${temp_repo_string#*:}"
local rclone_remote_string=":b2,account='${B2_ACCOUNT_ID}',key='${B2_ACCOUNT_KEY}':${b2_bucket}/${b2_path}"
local size_json
size_json=$("$GUM_CMD" spin --spinner dot --title "Calculating repository size with rclone..." -- \
"$RCLONE_CMD" size --json "$rclone_remote_string"
)
local size_data=""
if [[ "$size_json" == *"bytes"* ]]; then
local size_parser_code='
$json_str = file_get_contents("php://stdin");
$data = json_decode($json_str, true);
if (json_last_error() === JSON_ERROR_NONE) {
$bytes = $data["bytes"] ?? 0;
$count = $data["count"] ?? 0;
$size_formatted = "0 B";
if ($bytes >= 1073741824) { $size_formatted = round($bytes / 1073741824, 2) . " GiB"; }
elseif ($bytes >= 1048576) { $size_formatted = round($bytes / 1048576, 2) . " MiB"; }
elseif ($bytes >= 1024) { $size_formatted = round($bytes / 1024, 2) . " KiB"; }
elseif ($bytes > 0) { $size_formatted = $bytes . " B"; }
echo "Total Size," . $size_formatted . "\n";
echo "Total Files," . $count . "\n";
}
'
size_data=$(echo "$size_json" | php -r "$size_parser_code")
fi
# --- Get Snapshot Info from Restic ---
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching snapshot list..." -- \
"$RESTIC_CMD" snapshots --json)
if [ $? -ne 0 ]; then
echo "❌ Error fetching snapshots from restic." >&2
echo "Restic output: $snapshots_json" >&2
return 1
fi
# Verify JSON and default to empty array if invalid
if ! printf "%s" "$snapshots_json" | "$GUM_CMD" format >/dev/null 2>&1; then
snapshots_json="[]"
fi
# --- MODIFIED: Pipe Restic JSON directly to PHP parser ---
local php_parser_code_info='
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
// Exit if JSON is invalid or empty after decoding
if (json_last_error() !== JSON_ERROR_NONE || !is_array($snapshots)) { exit(0); }
$snapshot_count = count($snapshots);
$oldest_date = "N/A";
$newest_date = "N/A";
if ($snapshot_count > 0) {
$timestamps = array_map(function($s) {
return isset($s["time"]) ? strtotime($s["time"]) : 0;
}, $snapshots);
$timestamps = array_filter($timestamps);
if(count($timestamps) > 0) {
$oldest_ts = min($timestamps);
$newest_ts = max($timestamps);
$oldest_date = date("Y-m-d H:i:s T", $oldest_ts);
$newest_date = date("Y-m-d H:i:s T", $newest_ts);
}
}
echo "Snapshot Count," . $snapshot_count . "\n";
echo "Oldest Snapshot," . $oldest_date . "\n";
echo "Newest Snapshot," . $newest_date . "\n";
'
local info_data
info_data=$(printf "%s" "$snapshots_json" | php -r "$php_parser_code_info")
echo "--- Repository Information ---"
(
echo "Statistic,Value"
echo "B2 Bucket,${b2_bucket}"
echo "B2 Path,${b2_path}"
if [ -n "$size_data" ]; then echo "$size_data"; fi
if [ -n "$info_data" ]; then echo "$info_data"; fi
) | "$GUM_CMD" table --print --separator "," --widths=20,40
}
# ----------------------------------------------------
# Prunes the Restic repository to remove unneeded data.
# ----------------------------------------------------
function vault_prune() {
echo "🚀 Preparing to prune the Restic repository..."
echo "This command removes old data that is no longer needed."
echo "It can be a long-running process and will lock the repository."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- User Confirmation ---
echo "Repository: ${RESTIC_REPOSITORY}"
if ! "$GUM_CMD" confirm "Are you sure you want to prune this repository?"; then
echo "Prune operation cancelled."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
# --- Run Restic Prune with lock detection ---
echo " - Starting prune operation. This may take a while..."
local prune_output
# Capture all output (stdout and stderr) to check for the lock message
prune_output=$("$RESTIC_CMD" prune 2>&1)
local prune_exit_code=$?
# Check if the prune command failed
if [ $prune_exit_code -ne 0 ]; then
# If it failed, check if it was due to a lock
if echo "$prune_output" | grep -q "unable to create lock"; then
echo "⚠️ The repository is locked. A previous operation may have failed or is still running."
echo "$prune_output" # Show the user the detailed lock info from restic
if "$GUM_CMD" confirm "Do you want to attempt to remove the stale lock and retry?"; then
echo " - Attempting to unlock repository..."
if ! "$RESTIC_CMD" unlock; then
echo "❌ Error: Failed to unlock the repository. Please check it manually." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
echo " - Unlock successful. Retrying prune operation..."
if ! "$RESTIC_CMD" prune; then
echo "❌ Error: Restic prune command failed even after unlocking." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
echo "Prune operation cancelled due to locked repository."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
else
# The failure was for a reason other than a lock
echo "❌ Error: Restic prune command failed." >&2
echo "$prune_output" >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
fi
# --- Cleanup ---
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Vault prune complete!"
}
# ----------------------------------------------------
# Deletes a specific snapshot from the Restic repository.
# ----------------------------------------------------
function vault_delete() {
local snapshot_id="$1"
if [ -z "$snapshot_id" ]; then
echo "❌ Error: You must provide a snapshot ID to delete." >&2
show_command_help "vault" >&2
return 1
fi
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
echo "You are about to permanently delete snapshot: ${snapshot_id}"
echo "This action cannot be undone."
if ! "$GUM_CMD" confirm "Are you sure you want to delete this snapshot?"; then
echo "Delete operation cancelled."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
echo " - Deleting snapshot ${snapshot_id}..."
local forget_output
# Capture stdout and stderr to check for errors
forget_output=$("$RESTIC_CMD" forget "$snapshot_id" 2>&1)
local forget_exit_code=$?
# Check if the command failed
if [ $forget_exit_code -ne 0 ]; then
# If it failed, check if it was due to a lock
if echo "$forget_output" | grep -q "unable to create lock"; then
echo "⚠️ The repository is locked. A previous operation may have failed or is still running."
echo "$forget_output"
if "$GUM_CMD" confirm "Do you want to attempt to remove the stale lock and retry?"; then
echo " - Attempting to unlock repository..."
if ! "$RESTIC_CMD" unlock; then
echo "❌ Error: Failed to unlock the repository. Please check it manually." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
echo " - Unlock successful. Retrying delete operation..."
if ! "$RESTIC_CMD" forget "$snapshot_id"; then
echo "❌ Error: Restic forget command failed even after unlocking." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
echo "Delete operation cancelled due to locked repository."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
else
# The failure was for a reason other than a lock
echo "❌ Error: Failed to delete snapshot ${snapshot_id}." >&2
echo "$forget_output" >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
# Print the success output from the first attempt
echo "$forget_output"
fi
# --- Cleanup ---
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Snapshot ${snapshot_id} has been forgotten."
echo "💡 Note: This only removes the snapshot reference. To free up storage space by removing the underlying data, run '_do vault prune'."
}
function vault_snapshot_info() {
local snapshot_id="$1"
if [ -z "$snapshot_id" ];
then
echo "❌ Error: You must provide a snapshot ID."
show_command_help "vault" >&2
return 1
fi
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
if ! command -v php &>/dev/null; then
echo "❌ Error: The 'php' command is required for this operation."
>&2
return 1
fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1
fi
# --- Fetch Snapshot Data ---
echo "🔎 Fetching information for snapshot ${snapshot_id}..."
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching repository data..." -- \
"$RESTIC_CMD" snapshots --json)
if [ $? -ne 0 ]; then
echo "❌ Error: Failed to fetch snapshot list from the repository."
>&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
# --- PHP Parser ---
# This script searches the JSON list for the specific snapshot ID and falls back to 'restic stats' if needed.
local php_parser_code='
$target_id = $argv[1] ?? "";
$restic_cmd = $argv[2] ?? "restic";
if (empty($target_id)) { exit(1);
}
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($snapshots)) {
exit(1);
}
$found_snap = null;
foreach ($snapshots as $snap) {
if ((isset($snap["id"]) && strpos($snap["id"], $target_id) === 0) || (isset($snap["short_id"]) && $snap["short_id"] === $target_id)) {
$found_snap = $snap;
break;
}
}
if ($found_snap === null) {
fwrite(STDERR, "Snapshot with ID starting with \"$target_id\" not found.\n");
exit(1);
}
$snap = $found_snap;
function format_bytes($bytes) {
$bytes = (float)$bytes;
if ($bytes >= 1073741824) { return round($bytes / 1073741824, 2) . " GB"; }
elseif ($bytes >= 1048576) { return round($bytes / 1048576, 2) . " MB"; }
elseif ($bytes >= 1024) { return round($bytes / 1024, 2) . " KB"; }
elseif ($bytes > 0) { return $bytes . " B"; }
else { return "0 B"; }
}
$output = [];
$output["ID"] = $snap["short_id"] ?? "N/A";
$output["Time"] = isset($snap["time"]) ? (new DateTime($snap["time"]))->format("Y-m-d H:i:s T") : "N/A";
$output["Parent"] = $snap["parent"] ?? "None";
$output["Paths"] = isset($snap["paths"]) && is_array($snap["paths"]) ? implode("\n", $snap["paths"]) : "N/A";
$size_formatted = "N/A";
$data_added_formatted = "N/A";
if (isset($snap["summary"]["total_bytes_processed"])) {
$size_formatted = format_bytes($snap["summary"]["total_bytes_processed"]);
if (isset($snap["summary"]["data_added"])) {
$data_added_formatted = format_bytes($snap["summary"]["data_added"]);
}
} else {
fwrite(STDERR, "Snapshot summary not found. Calculating size with '\''restic stats'\''.\n");
$full_snapshot_id = $snap["id"];
$stats_json = shell_exec(escapeshellarg($restic_cmd) . " stats --json " . escapeshellarg($full_snapshot_id));
if ($stats_json) {
$stats_data = json_decode($stats_json, true);
if (json_last_error() === JSON_ERROR_NONE && isset($stats_data["total_size"])) {
$size_formatted = format_bytes($stats_data["total_size"]);
}
}
}
$output["Size (Full)"] = $size_formatted;
$output["Data Added (Unique)"] = $data_added_formatted;
foreach($output as $key => $value) {
// Using addslashes and quoting to handle multi-line paths and other special characters
echo $key . "," . "\"" . addslashes($value) . "\"\n";
}
'
# --- Process and Display ---
local info_data
info_data=$(echo "$snapshots_json" | php -r "$php_parser_code" "$snapshot_id" "$RESTIC_CMD")
# If the PHP script returns no data, the snapshot was not found in the JSON.
if [ -z "$info_data" ]; then
echo "❌ Error: Could not find data for snapshot '${snapshot_id}' in the repository list." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
# Unset variables for security
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
# Display the final table
echo "--- Snapshot Information ---"
(
echo "Property,Value"
# The PHP script now correctly escapes output for the table
echo "$info_data"
) |
"$GUM_CMD" table --print --separator "," --widths=25,0
}
# ----------------------------------------------------
# Displays the version of the _do script.
# ----------------------------------------------------
function show_version() {
echo "_do version $CAPTAINCORE_DO_VERSION"
}
# ----------------------------------------------------
# Checks for and identifies sources of WP-CLI warnings.
# ----------------------------------------------------
function wpcli_check() {
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! "$WP_CLI_CMD" core is-installed --quiet; then echo "❌ Error: This does not appear to be a WordPress installation." >&2; return 1; fi
echo "🚀 Checking for WP-CLI warnings..."
# 1. Run with everything skipped to check for core issues.
local base_warnings
base_warnings=$("$WP_CLI_CMD" plugin list --skip-themes --skip-plugins 2>&1 >/dev/null)
if [[ -n "$base_warnings" ]]; then
echo "⚠️ Found warnings even with all plugins and themes skipped. This might be a WP-CLI core or WordPress core issue."
echo "--- Warnings ---"
echo "$base_warnings"
echo "----------------"
return 1
fi
# 2. Run with everything active to get a baseline.
local initial_warnings
initial_warnings=$("$WP_CLI_CMD" plugin list 2>&1 >/dev/null)
if [[ -z "$initial_warnings" ]]; then
echo "✅ WP-CLI is running smoothly. No warnings detected."
return 0
fi
echo "⚠️ WP-CLI produced warnings. Investigating the source..."
echo
echo "--- Initial Warnings Found ---"
echo "$initial_warnings"
echo "----------------------------"
echo
local culprit_found=false
# 3. Check theme impact
echo "Testing for theme conflicts..."
local warnings_without_theme
warnings_without_theme=$("$WP_CLI_CMD" plugin list --skip-themes 2>&1 >/dev/null)
if [[ -z "$warnings_without_theme" ]]; then
local active_theme
active_theme=$("$WP_CLI_CMD" theme list --status=active --field=name)
echo "✅ Problem resolved by skipping themes. The active theme '$active_theme' is the likely source of the warnings."
culprit_found=true
else
echo "No warnings seem to originate from the theme."
fi
# 4. Check plugin impact
echo "Testing for plugin conflicts..."
local active_plugins=()
while IFS= read -r line; do
active_plugins+=("$line")
done < <("$WP_CLI_CMD" plugin list --field=name --status=active)
if [[ ${#active_plugins[@]} -eq 0 ]]; then
echo " No active plugins found to test."
else
echo "Comparing output when skipping each of the ${#active_plugins[@]} active plugins..."
for plugin in "${active_plugins[@]}"; do
printf " - Testing by skipping '%s'... " "$plugin"
local warnings_without_plugin
warnings_without_plugin=$("$WP_CLI_CMD" plugin list --skip-plugins="$plugin" 2>&1 >/dev/null)
if [[ -z "$warnings_without_plugin" ]]; then
printf "FOUND CULPRIT\n"
echo " ✅ Warnings disappeared when skipping '$plugin'. This plugin is a likely source of the warnings."
culprit_found=true
else
printf "no change\n"
fi
done
fi
echo
if ! $culprit_found; then
echo " Could not isolate a single plugin or theme as the source. The issue might be from a combination of plugins or WordPress core itself."
fi
echo "✅ Check complete."
}
# ----------------------------------------------------
# Creates a zip archive of a specified folder.
# ----------------------------------------------------
function run_zip() {
local target_folder="$1"
# --- 1. Validate Input ---
if [ -z "$target_folder" ]; then
echo "Error: No folder specified." >&2
echo "Usage: _do zip \"<folder>\"" >&2
return 1
fi
if [ ! -d "$target_folder" ]; then
echo "Error: Folder '$target_folder' not found." >&2
return 1
fi
if ! command -v realpath &> /dev/null; then
echo "Error: 'realpath' command is required but not found." >&2
return 1
fi
# --- 2. Determine Paths and Names ---
local full_target_path
full_target_path=$(realpath "$target_folder")
local parent_dir
parent_dir=$(dirname "$full_target_path")
local dir_to_zip
dir_to_zip=$(basename "$full_target_path")
local output_zip_file="${dir_to_zip}.zip"
local output_zip_path="${parent_dir}/${output_zip_file}"
# Prevent zipping to the same name if it already exists
if [ -f "$output_zip_path" ]; then
echo "Error: A file named '${output_zip_file}' already exists in the target directory." >&2
return 1
fi
echo "🚀 Creating zip archive for '${dir_to_zip}'..."
# --- 3. Create Zip Archive ---
# Change to the parent directory to ensure clean paths inside the zip
local original_dir
original_dir=$(pwd)
cd "$parent_dir" || { echo "Error: Could not change to directory '$parent_dir'." >&2; return 1; }
# Create the zip file, excluding common unnecessary files
if ! zip -r "$output_zip_file" "$dir_to_zip" -x "*.git*" "*.DS_Store*" "*node_modules*" > /dev/null; then
echo "Error: Failed to create zip archive." >&2
cd "$original_dir"
return 1
fi
# Return to the original directory
cd "$original_dir"
# --- 4. Final Report ---
local file_size
file_size=$(du -h "$output_zip_path" | cut -f1 | xargs)
local final_output_path="$output_zip_path"
# --- WordPress URL Logic ---
local zip_url=""
# Silently check if WP-CLI is available and we're in a WordPress installation.
# This check works by traversing up from the current directory.
if setup_wp_cli &>/dev/null && "$WP_CLI_CMD" core is-installed --quiet 2>/dev/null; then
local wp_home
wp_home=$("$WP_CLI_CMD" option get home --skip-plugins --skip-themes 2>/dev/null)
if [ -n "$wp_home" ]; then
local wp_root_path
# Use `wp config path` to reliably find the WP root.
wp_root_path=$("$WP_CLI_CMD" config path --quiet 2>/dev/null)
if [ -n "$wp_root_path" ] && [ -f "$wp_root_path" ]; then
wp_root_path=$(dirname "$wp_root_path")
# The zip file is created in `parent_dir`. We need its path relative to the WP root.
local relative_zip_dir_path
relative_zip_dir_path=${parent_dir#"$wp_root_path"}
# Construct the final URL
zip_url="${wp_home%/}${relative_zip_dir_path}/${output_zip_file}"
fi
fi
fi
# --- End WordPress URL Logic ---
echo "✅ Zip archive created successfully."
if [ -n "$zip_url" ]; then
echo " Link: $zip_url ($file_size)"
else
echo " File: $final_output_path ($file_size)"
fi
}
# Pass all script arguments to the main function.
main "$@"