#!/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 [--quiet]"
echo
echo "Flags:"
echo " --exclude= 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 [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 [] Reverts the site to the specified checkpoint hash."
echo " show 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 "
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 [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 \"\" \"\" \"\" Adds a new command to the schedule."
echo " delete Deletes a command from the schedule."
echo
echo "Arguments for 'add':"
echo " (Required) The _do command to run, in quotes (e.g., \"update all\")."
echo " (Required) The next run time, in quotes (e.g., \"4am\", \"tomorrow 2pm\", \"+2 hours\")."
echo " (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 "
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 [--debug]"
echo
echo "Arguments:"
echo " (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 \"\" [-x ] [-x ]..."
echo
echo "Arguments:"
echo " (Required) The path and file pattern to search for, enclosed in quotes."
echo
echo "Flags:"
echo " -x (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 [arguments]"
echo
echo "Subcommands:"
echo " recent-files [days] Finds files modified within the last . Defaults to 1 day."
echo " slow-plugins [path] Identifies plugins slowing down WP-CLI. Optionally check a specific page path (e.g., \"/contact\")."
echo " stuck-plugin Identifies a plugin causing WP-CLI commands to hang by timing them out."
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 [--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=]"
echo
echo "Flags:"
echo " --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= [--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 [--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= 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 [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)."
;;
screenshot)
echo "Takes a full-page screenshot of a given URL using Playwright."
echo
echo "Usage: _do screenshot "
echo
echo "Arguments:"
echo " (Required) The full URL to capture."
echo
echo "The screenshot will be saved as a .png file in the current directory."
;;
suspend)
echo "Activates or deactivates a suspend message shown to visitors."
echo
echo "Usage: _do suspend [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= (Required) The name of the business to display."
echo " --link= (Required) The contact link for the business."
echo " --wp-content= (Optional) Path to wp-content directory. Defaults to 'wp-content'."
echo
echo "Flags for 'deactivate':"
echo " --wp-content= (Optional) Path to wp-content directory. Defaults to 'wp-content'."
;;
update)
echo "Handles WordPress core, theme, and plugin updates."
echo
echo "Usage: _do update "
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 [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 Displays detailed information for a single snapshot."
echo " delete 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 "
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 \"\""
echo
echo "Arguments:"
echo " (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 [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/stuck 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 " screenshot Takes a full-page screenshot of a URL."
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 ' 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 ' pattern
if [[ "$1" == "help" ]]; then
show_command_help "$2"
exit 0
fi
# Detect ' --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 \"\" [-x ...]" >&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]}"
;;
stuck-plugin)
find_stuck_plugin
;;
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
;;
screenshot)
run_screenshot "${positional_args[1]}" "$debug_flag"
;;
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 three arguments
local backup_exclude_patterns=("$@") # The rest of the arguments are the exclude patterns
# --- 2. Validation & Tool Selection ---
local archive_cmd=""
local backup_extension=""
if command -v zip &> /dev/null; then
archive_cmd="zip"
backup_extension="zip"
elif command -v tar &> /dev/null; then
archive_cmd="tar"
backup_extension="tar.gz"
else
echo "Error: Neither 'zip' nor 'tar' command found. Please install one of them." >&2
return 1
fi
if [ -z "$target_folder" ]; then
echo "Error: Please provide a folder path." >&2; echo "Usage: _do backup " >&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 ---
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}.${backup_extension}"
local original_dir; original_dir=$(pwd)
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
# --- 5. Archive Creation ---
if [[ "$quiet_flag" != "true" ]]; then
echo "Creating ${backup_extension} archive using '${archive_cmd}'...";
if [ ${#backup_exclude_patterns[@]} -gt 0 ]; then
echo "Excluding the following patterns:";
for pattern in "${backup_exclude_patterns[@]}"; do
echo " - $pattern";
done
fi
fi
# Define the primary source directory for the archive.
local source_paths=("$site_dir_name")
# Check for wp-config.php one level up from the WordPress root and add it to the archive if found.
if [ -f "wp-config.php" ]; then
source_paths+=("wp-config.php")
if [[ "$quiet_flag" != "true" ]]; then
echo "Including 'wp-config.php' from parent directory."
fi
fi
local archive_failed=false
if [[ "$archive_cmd" == "zip" ]]; then
local zip_exclude_args=()
zip_exclude_args+=(-x "$site_dir_name/wp-content/updraft/*") # Default exclude
for pattern in "${backup_exclude_patterns[@]}"; do
zip_exclude_args+=(-x "$site_dir_name/$pattern*")
done
# Zip the site directory and the wp-config.php if it exists one level up
if ! zip -r "$backup_filename" "${source_paths[@]}" "${zip_exclude_args[@]}" > /dev/null; then
archive_failed=true
fi
elif [[ "$archive_cmd" == "tar" ]]; then
local tar_exclude_args=()
tar_exclude_args+=(--exclude="$site_dir_name/wp-content/updraft") # Default exclude
for pattern in "${backup_exclude_patterns[@]}"; do
tar_exclude_args+=(--exclude="$site_dir_name/$pattern")
done
# Create the tarball including the site directory and wp-config.php if present
if ! tar -czf "$backup_filename" "${tar_exclude_args[@]}" "${source_paths[@]}" > /dev/null; then
archive_failed=true
fi
fi
if [[ "$archive_failed" == "true" ]]; then
echo "Error: Failed to create archive." >&2
rm -f "$site_dir_name/$database_file"
cd "$original_dir"
return 1
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/"
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 < "$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='
$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='
/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='
&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='
&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'
" . ($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='
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'
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 [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 \"\" [-x ]..." >&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, .DS_Store files"
# 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
# Exclude the output file by *both* name and relative path.
# This robustly prevents the dump file from including itself.
find_cmd+=("-not" "-name" "$OUTPUT_FILE")
find_cmd+=("-not" "-path" "./$OUTPUT_FILE")
# Automatically exclude .git and .DS_Store files
find_cmd+=("-not" "-path" "*/.git/*")
find_cmd+=("-not" "-name" ".DS_Store")
# 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=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
# --- 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 " >&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 '/dev/null \
| grep -v -F -e '&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 timeout &> /dev/null; then echo " Error: 'timeout' command not found. This is required to detect hanging processes." >&2; return 1; fi
# --- Baseline Check ---
echo " Performing a baseline check to confirm WP-CLI is hanging..."
timeout 3s "$WP_CLI_CMD" option get home >/dev/null 2>&1
if [ $? -eq 0 ]; then
echo " Baseline check successful. WP-CLI doesn't appear to be hanging."
return 0
else
echo " Baseline check timed out. Confirmed that WP-CLI is hanging, proceeding with test."
fi
echo
echo " 🚀 Starting stuck plugin check..."
echo " This will test each active plugin to see if skipping it allows WP-CLI to respond quickly."
echo
# --- List of active plugins to test ---
local plugins
plugins=$("$WP_CLI_CMD" plugin list --field=name --status=active --skip-plugins)
if [ -z "$plugins" ]; then
echo " No active plugins found to test."
return 0
fi
# --- Loop through each plugin ---
for plugin in $plugins; do
echo -n " Testing with '$plugin' skipped..."
# Run the WP-CLI command with a 3-second timeout.
# We silence the output since we only care if it succeeds.
timeout 3s "$WP_CLI_CMD" option get home --skip-plugins="$plugin" >/dev/null 2>&1
# Check the exit code of the last command
if [ $? -eq 0 ]; then
# An exit code of 0 means the command completed successfully within the time limit.
echo " ✅ SUCCESS!"
echo "--------------------------------------------------------"
echo " Found the culprit! The site responded quickly when skipping:"
echo " ➡️ $plugin"
echo "--------------------------------------------------------"
return 0
else
# A non-zero exit code means it timed out or failed.
echo " ❌ Timeout."
fi
done
echo "--------------------------------------------------------"
echo " ⏹️ Finished testing all plugins. No single plugin could be identified as the culprit."
}
# ----------------------------------------------------
# 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."
}
# ----------------------------------------------------
# Takes a screenshot of a given URL using Playwright.
# ----------------------------------------------------
function run_screenshot() {
local target_url="$1"
local debug_flag="$2" # Accept the debug flag as the second argument
if [ -z "$target_url" ]; then
echo "❌ Error: Missing required URL argument." >&2
show_command_help "screenshot"
return 1
fi
# Pre-flight check for Playwright and cwebp
if ! setup_playwright; then return 1; fi
if ! setup_cwebp; then return 1; fi # Ensure cwebp is ready
# Generate a clean PNG filename first
local png_filename
png_filename=$(echo "$target_url" | sed -E 's#^https?://##; s#[/#\.]#_#g')
png_filename="screenshot_${png_filename}_$(date +%Y%m%d_%H%M%S).png"
local webp_filename="${png_filename%.png}.webp"
echo "📸 Taking screenshot of ${target_url}..."
echo " - Saving intermediate PNG to: $(pwd)/${png_filename}"
# Define the advanced, self-contained Playwright script
local PLAYWRIGHT_SCRIPT
read -r -d '' PLAYWRIGHT_SCRIPT <<'EOF'
// --- Embedded Advanced Playwright Script (Official Library Only) ---
const { chromium } = require('playwright');
async function main() {
const { errors } = require('playwright');
const [, , url, outputPath, debugFlag] = process.argv;
if (!url || !outputPath) {
console.error('Usage: node screenshot.js [--debug]');
process.exit(1);
}
const GOTO_TIMEOUT = 90000;
const IDLE_TIMEOUT = 2000;
const isHeadless = debugFlag !== 'true';
const browser = await chromium.launch({ headless: isHeadless });
const context = await browser.newContext({
ignoreHTTPSErrors: true,
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();
await page.route('**/*', (route) => {
const requestUrl = route.request().url();
const adPatterns = [
'doubleclick.net', 'google-analytics.com', 'googlesyndication.com',
'adservice.google.com', 'connect.facebook.net', 'analytics.twitter.com',
'scorecardresearch.com', 'googletagservices.com'
];
if (adPatterns.some(pattern => requestUrl.includes(pattern))) {
return route.abort();
}
return route.continue();
});
try {
process.stdout.write(` - Navigating to ${url}...`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: GOTO_TIMEOUT });
console.log(' Done.');
// AGGRESSIVE FIX: Find ALL lazy-loading elements and remove the attribute.
process.stdout.write(` - Aggressively disabling all native lazy-loading...`);
await page.evaluate(() => {
const lazyElements = document.querySelectorAll('[loading="lazy"]');
lazyElements.forEach(el => {
el.removeAttribute('loading');
});
});
console.log(' Done.');
try {
process.stdout.write(` - Waiting for initial content (max 2s)...`);
await page.waitForLoadState('networkidle', { timeout: IDLE_TIMEOUT });
console.log(' Done.');
} catch (e) {
if (e instanceof errors.TimeoutError) {
console.log(' Timed out.');
} else { throw e; }
}
process.stdout.write(` - Scrolling to bottom with PageDown...`);
let stableScrolls = 0;
while (stableScrolls < 3) {
const lastHeight = await page.evaluate('document.body.scrollHeight');
await page.keyboard.press('PageDown');
await page.waitForTimeout(300);
const newHeight = await page.evaluate('document.body.scrollHeight');
if (newHeight === lastHeight) {
stableScrolls++;
} else {
stableScrolls = 0;
}
}
console.log(' Done.');
process.stdout.write(` - Forcing media to load: promote data-srcs, reattach iframes, scroll each embed...\n`);
// 1) Promote common lazy data-* attributes to real src/srcset and remove loading attr
await page.evaluate(() => {
const copyDataToAttr = (el, targetAttr, candidates) => {
for (const d of candidates) {
if (el.hasAttribute(d)) {
const v = el.getAttribute(d);
if (v) el.setAttribute(targetAttr, v);
// keep going — sometimes both data-src and data-srcset exist
}
}
};
const imgCandidates = ['data-src', 'data-lazy-src', 'data-original', 'data-srcset', 'data-lazy-srcset'];
document.querySelectorAll('img').forEach(img => {
copyDataToAttr(img, 'src', imgCandidates);
if (img.dataset && img.dataset.srcset) img.setAttribute('srcset', img.dataset.srcset);
img.removeAttribute('loading');
});
document.querySelectorAll('source').forEach(src => {
copyDataToAttr(src, 'srcset', ['data-srcset', 'data-lazy-srcset', 'data-src']);
});
// iframe/video: move data-src -> src if present and remove loading attr
document.querySelectorAll('iframe, video').forEach(el => {
copyDataToAttr(el, 'src', ['data-src', 'data-lazy-src', 'data-original']);
el.removeAttribute('loading');
});
// For plugin placeholders that create actual iframe on click, attempt to clone existing iframe(s)
document.querySelectorAll('iframe[src*="youtube.com"]').forEach(iframe => {
// replace with a fresh clone — this forces the browser to (re)fetch the src
try {
const clone = iframe.cloneNode(true);
iframe.parentNode.replaceChild(clone, iframe);
} catch (e) {
// ignore; replacement is best-effort
}
});
});
console.log(' - Data attribute promotion + iframe replacement done.');
// 2) Scroll to bottom explicitly (reliable) and give network some time to initialize lazy loaders
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(1200);
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {/* ignore timeout */});
// 3) Find every YouTube iframe, scroll it into view and try to interact inside its frame
const frames = await page.$$('iframe[src*="youtube.com"]');
for (let i = 0; i < frames.length; i++) {
try {
// bring iframe into viewport
await frames[i].evaluate(node => node.scrollIntoView({ block: 'center', behavior: 'instant' }));
await page.waitForTimeout(400);
// try to access the content frame and hover the play button to force thumbnail/player rendering
const childFrame = await frames[i].contentFrame();
if (childFrame) {
const playButton = await childFrame.waitForSelector('.ytp-large-play-button', { timeout: 3000 }).catch(() => null);
if (playButton) {
await playButton.hover().catch(() => null);
await page.waitForTimeout(500);
}
}
} catch (e) {
// per-iframe errors are non-fatal — continue to others
}
// small gap between iframes
await page.waitForTimeout(250);
}
console.log(' - Per-iframe interaction complete, giving final settle pause...');
await page.waitForTimeout(800);
process.stdout.write(` - Scrolling back to top...`);
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(1000);
console.log(' Done.');
process.stdout.write(` - Capturing final PNG screenshot...`);
await page.screenshot({
path: outputPath,
fullPage: true,
type: 'png',
animations: 'disabled'
});
console.log(' Done.');
} catch (error) {
console.error(`\n❌ Error during automation: ${error.message}`);
process.exit(1);
} finally {
await browser.close();
}
}
main();
EOF
# Execute the Playwright script to get the PNG
if echo "$PLAYWRIGHT_SCRIPT" | node - "$target_url" "$png_filename" "$debug_flag"; then
echo " - Converting PNG to WebP..."
# Use cwebp to convert the PNG to WebP
if "$CWEBP_CMD" -q 80 "$png_filename" -o "$webp_filename" > /dev/null 2>&1; then
rm "$png_filename" # Clean up the intermediate PNG file
echo "✅ Screenshot saved successfully: $webp_filename"
else
echo "⚠️ Warning: Could not convert to WebP. The PNG screenshot is still available: $png_filename"
fi
else
echo "❌ Failed to take screenshot. Check errors above." >&2
# Clean up the empty/failed file if it exists
[ -f "$png_filename" ] && rm "$png_filename"
return 1
fi
}
# ----------------------------------------------------
# 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 < "$output_file"
Website Suspended
Website Suspended
This website is currently unavailable.
Site owners may contact ${name} .
/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='
&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='
/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='
/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='
$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" "$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='
$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='
= 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 \"\"" >&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 "$@"