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