] [--profile=] * wp spinx-qm export [--files] [--media] [--themes[=]] [--plugins[=]] * [--muplugins] [--core] [--skip-db] [--exclude=] * [--tables=] [--post-types=] [--profile=] [--config=] * wp spinx-qm find-replace --url-from= --url-to= [--path-from=

--path-to=

] * [--find= --replace= [--case-sensitive]] [--tables=] * [--backup] [--dry-run] [--profile=] * wp spinx-qm profile [] * * @package SpinxQuickMigrate */ namespace SpinxQM; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * WP-CLI `spinx-qm` command: export, backup, find & replace and saved profiles. */ class Cli { /** A driven job's per-step ceiling — a backstop against a stuck step machine. */ const MAX_STEPS = 2000000; /** * Register the `spinx-qm` command under WP-CLI. */ public static function register(): void { if ( ! class_exists( '\WP_CLI' ) ) { return; } \WP_CLI::add_command( 'spinx-qm', self::class ); } /** * Back up the database to a .sql / .sql.gz in plugin storage. * * ## OPTIONS * * [--gzip] * : Compress the dump (.sql.gz). * * [--tables=] * : Comma-separated table names; default = all tables with this site's prefix. * * [--profile=] * : Use a saved profile's table selection as the base. * * @when after_wp_load * * @param array $args Positional arguments. * @param array $assoc Associative (flag) arguments. */ public function backup( $args, $assoc ): void { $config = self::build_config( $assoc ); $res = Rest_Controller::backup_start( self::request_with_config( $config ) ); if ( empty( $res['job_id'] ) ) { \WP_CLI::error( 'Could not start the backup.' ); } $result = self::drive( (string) $res['job_id'], 'Backup' ); \WP_CLI::success( 'Backup complete: ' . (string) ( $result['filename'] ?? $result['file'] ?? '' ) ); } /** * Export the site (database + selected files) to an archive. * * ## OPTIONS * * [--files] * : Include media, themes, plugins, and mu-plugins (everything but core). * * [--media] * : Include the uploads (media) directory. * * [--themes[=]] * : Include themes. Pass a comma-separated slug list to include only those; bare = all. * * [--plugins[=]] * : Include plugins. Pass a comma-separated slug list to include only those; bare = all. * * [--muplugins] * : Include the mu-plugins directory. * * [--core] * : Include WordPress core files. * * [--skip-db] * : Exclude the database (files-only export). * * [--exclude=] * : Comma/newline-separated exclusion patterns applied to the file scan. * * [--tables=] * : Export only these tables (default = all prefix tables). * * [--post-types=] * : Limit exported posts to these post types. * * [--profile=] * : Start from a saved profile's config; individual flags override it. * * [--config=] * : Raw JSON export config (same shape the Export tab sends); flags override it. * * @when after_wp_load * * @param array $args Positional arguments. * @param array $assoc Associative (flag) arguments. */ public function export( $args, $assoc ): void { $config = self::build_config( $assoc ); $res = Rest_Controller::export_start( self::request_with_config( $config ) ); if ( empty( $res['job_id'] ) ) { \WP_CLI::error( 'Could not start the export.' ); } $result = self::drive( (string) $res['job_id'], 'Export' ); \WP_CLI::success( 'Export complete: ' . (string) ( $result['filename'] ?? $result['file'] ?? '' ) ); } /** * Run a URL / path find & replace against the database. * * Site URL is required (a complete current→new pair), mirroring the admin UI. Site * Path and custom find/replace rules are optional and applied on top. * * ## OPTIONS * * --url-from= * : The current site URL (e.g. https://old.example). * * --url-to= * : The new site URL (e.g. https://new.example). * * [--path-from=] * : The current filesystem path (optional; requires --path-to). * * [--path-to=] * : The new filesystem path (optional; requires --path-from). * * [--find=] * : An additional literal search string (custom rule). * * [--replace=] * : The replacement for --find. * * [--case-sensitive] * : Make the --find/--replace custom rule case-sensitive. * * [--tables=] * : Limit the replace to these tables (default = all prefix tables). * * [--backup] * : Take a pre-flight database backup first. * * [--dry-run] * : Preview the number of replacements without writing any changes. * * [--profile=] * : Start from a saved profile's config; flags override it. * * @subcommand find-replace * @when after_wp_load * * @param array $args Positional arguments. * @param array $assoc Associative (flag) arguments. */ public function find_replace( $args, $assoc ): void { $config = self::build_config( $assoc ); if ( isset( $assoc['url-from'] ) || isset( $assoc['url-to'] ) ) { $config['standard_rules'][0] = array( 'find' => (string) ( $assoc['url-from'] ?? '' ), 'replace' => (string) ( $assoc['url-to'] ?? '' ), ); } if ( isset( $assoc['path-from'] ) || isset( $assoc['path-to'] ) ) { $config['standard_rules'][1] = array( 'find' => (string) ( $assoc['path-from'] ?? '' ), 'replace' => (string) ( $assoc['path-to'] ?? '' ), ); } if ( isset( $config['standard_rules'] ) ) { ksort( $config['standard_rules'] ); $config['standard_rules'] = array_values( $config['standard_rules'] ); } if ( isset( $assoc['find'] ) || isset( $assoc['replace'] ) ) { $config['custom_rules'] = is_array( $config['custom_rules'] ?? null ) ? $config['custom_rules'] : array(); $config['custom_rules'][] = array( 'find' => (string) ( $assoc['find'] ?? '' ), 'replace' => (string) ( $assoc['replace'] ?? '' ), 'case_sensitive' => isset( $assoc['case-sensitive'] ), ); } if ( isset( $assoc['backup'] ) ) { $config['backup_before'] = true; $config['backup_before_mode'] = 'prefix'; } // The free engine consumes a pre-merged flat rules[] (the admin UI's readRules() // builds it from the standard presets + custom rows); compose the same here. $config['rules'] = self::flatten_rules( $config ); $req = self::request_with_config( $config ); if ( isset( $assoc['dry-run'] ) ) { $res = Rest_Controller::find_replace_preview( $req ); if ( is_wp_error( $res ) ) { \WP_CLI::error( $res->get_error_message() ); } $totals = is_array( $res['preview']['totals'] ?? null ) ? $res['preview']['totals'] : array(); \WP_CLI::log( sprintf( 'Dry run: %d replacement(s) across %d row(s).', (int) ( $totals['replacements'] ?? 0 ), (int) ( $totals['rows'] ?? 0 ) ) ); \WP_CLI::success( 'Preview complete (no changes written).' ); return; } $res = Rest_Controller::find_replace_apply( $req ); if ( is_wp_error( $res ) ) { \WP_CLI::error( $res->get_error_message() ); } if ( empty( $res['job_id'] ) ) { \WP_CLI::warning( (string) ( $res['message'] ?? 'Nothing to replace.' ) ); return; } self::drive( (string) $res['job_id'], 'Find & Replace' ); \WP_CLI::success( 'Find & replace complete.' ); } /** * List or run saved profiles. * * ## OPTIONS * * * : Either "list" (show saved profiles) or "run" (execute one by name). * * [] * : The profile name (required for "run"). * * @when after_wp_load * * @param array $args Positional arguments. * @param array $assoc Associative (flag) arguments. */ public function profile( $args, $assoc ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WP-CLI passes ($args, $assoc_args) to every command. $action = strtolower( (string) ( $args[0] ?? 'list' ) ); if ( 'list' === $action ) { $rows = array(); foreach ( Profile_Manager::get_saved() as $p ) { $rows[] = array( 'name' => (string) ( $p['name'] ?? '' ), 'type' => (string) ( $p['type'] ?? '' ), 'id' => (string) ( $p['id'] ?? '' ), ); } if ( empty( $rows ) ) { \WP_CLI::log( 'No saved profiles.' ); return; } \WP_CLI\Utils\format_items( 'table', $rows, array( 'name', 'type', 'id' ) ); return; } if ( 'run' !== $action ) { \WP_CLI::error( 'Unknown action. Use "list" or "run".' ); } $name = (string) ( $args[1] ?? '' ); if ( '' === $name ) { \WP_CLI::error( 'Provide a profile name: wp spinx-qm profile run "".' ); } $profile = self::profile_by_name( $name ); if ( null === $profile ) { /* translators: %s: profile name */ \WP_CLI::error( sprintf( __( 'Profile "%s" not found.', 'spinx-quick-migrate' ), $name ) ); } $type = (string) ( $profile['type'] ?? 'export' ); $payload = is_array( $profile['payload'] ?? null ) ? $profile['payload'] : array(); $req = self::request_with_config( $payload ); if ( 'export' === $type ) { $res = Rest_Controller::export_start( $req ); $result = self::drive( (string) ( $res['job_id'] ?? '' ), 'Export' ); \WP_CLI::success( 'Export complete: ' . (string) ( $result['filename'] ?? $result['file'] ?? '' ) ); return; } if ( 'backup' === $type ) { $res = Rest_Controller::backup_start( $req ); $result = self::drive( (string) ( $res['job_id'] ?? '' ), 'Backup' ); \WP_CLI::success( 'Backup complete: ' . (string) ( $result['filename'] ?? $result['file'] ?? '' ) ); return; } if ( 'find_replace' === $type ) { $res = Rest_Controller::find_replace_apply( $req ); if ( is_wp_error( $res ) ) { \WP_CLI::error( $res->get_error_message() ); } if ( empty( $res['job_id'] ) ) { \WP_CLI::warning( (string) ( $res['message'] ?? 'Nothing to replace.' ) ); return; } self::drive( (string) $res['job_id'], 'Find & Replace' ); \WP_CLI::success( 'Find & replace complete.' ); return; } /* translators: %s: profile type */ \WP_CLI::error( sprintf( __( 'Profiles of type "%s" cannot be run from the command line. Supported types: export, backup, find_replace.', 'spinx-quick-migrate' ), $type ) ); } /** * Build an export config from flags, an optional saved --profile, and/or a raw * --config JSON. Precedence: profile/config = base, individual flags override. * * @param array $assoc Associative (flag) arguments. * @return array */ private static function build_config( array $assoc ): array { $config = array(); if ( ! empty( $assoc['profile'] ) ) { $profile = self::profile_by_name( (string) $assoc['profile'] ); if ( null === $profile ) { /* translators: %s: profile name */ \WP_CLI::error( sprintf( __( 'Profile "%s" not found.', 'spinx-quick-migrate' ), (string) $assoc['profile'] ) ); } $config = is_array( $profile['payload'] ?? null ) ? $profile['payload'] : array(); } elseif ( ! empty( $assoc['config'] ) ) { $config = self::config_from( $assoc ); } $fo = is_array( $config['file_options'] ?? null ) ? $config['file_options'] : array(); // --files = media + themes + plugins + mu-plugins (not core). if ( isset( $assoc['files'] ) ) { foreach ( array( 'media', 'themes', 'plugins', 'mu-plugins' ) as $sec ) { $fo = self::enable_section( $fo, $sec ); } } if ( isset( $assoc['media'] ) ) { $fo = self::enable_section( $fo, 'media' ); } if ( isset( $assoc['muplugins'] ) ) { $fo = self::enable_section( $fo, 'mu-plugins' ); } if ( isset( $assoc['core'] ) ) { $fo = self::enable_section( $fo, 'core' ); } // --themes / --plugins may be bare (all) or carry a slug list (selected). foreach ( array( 'themes', 'plugins' ) as $sec ) { if ( ! array_key_exists( $sec, $assoc ) ) { continue; } $fo = self::enable_section( $fo, $sec ); $val = $assoc[ $sec ]; if ( is_string( $val ) && '' !== trim( $val ) ) { $slugs = array_values( array_filter( array_map( 'trim', explode( ',', $val ) ) ) ); if ( ! empty( $slugs ) ) { $fo[ $sec ]['mode'] = 'selected'; $fo[ $sec ]['selected'] = $slugs; } } } if ( ! empty( $fo ) ) { $config['file_options'] = $fo; } if ( ! empty( $assoc['exclude'] ) ) { $config['export_exclusions'] = array_values( array_filter( array_map( 'trim', (array) preg_split( '/[,\r\n]+/', (string) $assoc['exclude'] ) ) ) ); } if ( isset( $assoc['skip-db'] ) ) { $config['include_database'] = false; } if ( ! empty( $assoc['tables'] ) ) { $config['tables_mode'] = 'selected'; $config['tables'] = array_values( array_filter( array_map( 'trim', explode( ',', (string) $assoc['tables'] ) ) ) ); } if ( ! empty( $assoc['post-types'] ) ) { $config['post_types_mode'] = 'selected'; $config['post_types'] = array_values( array_filter( array_map( 'trim', explode( ',', (string) $assoc['post-types'] ) ) ) ); } if ( isset( $assoc['gzip'] ) ) { $config['gzip'] = true; } // The free engine's normalize_export_args has no "all prefix tables" fallback // (the admin UI always posts the full list). Supply it here whenever the database // is in scope so a flag-only run isn't rejected with "No valid tables selected". $skip_db = array_key_exists( 'include_database', $config ) && false === $config['include_database']; if ( empty( $config['tables'] ) && ! $skip_db ) { $config['tables'] = Helpers::all_prefix_tables(); } return $config; } /** * Flatten the standard + custom rule pairs into the single literal rules[] list the * free engine consumes (mirrors the admin UI's readRules(): skip any pair missing a * find or replace value). * * @param array $config Config holding standard_rules and custom_rules. * @return array> */ private static function flatten_rules( array $config ): array { $out = array(); foreach ( array( 'standard_rules', 'custom_rules' ) as $key ) { foreach ( (array) ( $config[ $key ] ?? array() ) as $rule ) { if ( ! is_array( $rule ) ) { continue; } $find = (string) ( $rule['find'] ?? '' ); $replace = (string) ( $rule['replace'] ?? '' ); if ( '' === trim( $find ) || '' === trim( $replace ) ) { continue; } $out[] = array( 'find' => $find, 'replace' => $replace, 'case_sensitive' => ! empty( $rule['case_sensitive'] ), ); } } return $out; } /** * Mark a file section enabled, defaulting its mode to "all". * * @param array $fo File options keyed by section. * @param string $section Section key (media, themes, plugins, mu-plugins, core). * @return array */ private static function enable_section( array $fo, string $section ): array { $fo[ $section ] = is_array( $fo[ $section ] ?? null ) ? $fo[ $section ] : array(); $fo[ $section ]['enabled'] = true; if ( empty( $fo[ $section ]['mode'] ) ) { $fo[ $section ]['mode'] = 'all'; } return $fo; } /** * Find a saved profile by (case-insensitive) name. * * @param string $name Profile name. * @return array|null */ private static function profile_by_name( string $name ): ?array { $needle = strtolower( trim( $name ) ); foreach ( Profile_Manager::get_saved() as $profile ) { if ( strtolower( (string) ( $profile['name'] ?? '' ) ) === $needle ) { return $profile; } } return null; } /** * Wrap a config array in a WP_REST_Request the *_start() methods can read. * * @param array $config Config set as the request's `config` param. */ private static function request_with_config( array $config ): \WP_REST_Request { $req = new \WP_REST_Request( 'POST' ); $req->set_param( 'config', $config ); return $req; } /** * Drive a job to a terminal state via job_step(), logging progress. Returns the * job result. (The free engine has no public advance_job() dispatcher, so we drive * through the same public step endpoint the browser uses.) * * @param string $job_id Job ID to drive. * @param string $label Human-readable label used in progress/error output. * @return array */ private static function drive( string $job_id, string $label ): array { $running = array( 'running', 'queued' ); $last_pct = -1; $req = new \WP_REST_Request( 'POST' ); $req->set_param( 'id', $job_id ); for ( $i = 0; $i < self::MAX_STEPS; $i++ ) { $state = Rest_Controller::job_step( $req ); if ( is_wp_error( $state ) ) { \WP_CLI::error( $state->get_error_message() ); } $status = (string) ( $state['status'] ?? '' ); $pct = (int) ( $state['progress'] ?? $state['percent'] ?? 0 ); if ( $pct !== $last_pct ) { \WP_CLI::log( sprintf( '%s: %d%% — %s', $label, $pct, (string) ( $state['message'] ?? '' ) ) ); $last_pct = $pct; } if ( in_array( $status, $running, true ) ) { continue; } if ( 'success' === $status || 'completed' === $status ) { $job = Helpers::get_job( $job_id ); return is_array( $job['result'] ?? null ) ? $job['result'] : array(); } if ( 'cancelled' === $status ) { \WP_CLI::error( $label . ' was cancelled.' ); } \WP_CLI::error( $label . ' failed: ' . (string) ( $state['message'] ?? 'unknown error' ) ); } \WP_CLI::error( $label . ' did not finish within the step limit.' ); return array(); } /** * Parse a `--config=` option into a config array (empty = defaults). * * @param array $assoc Associative (flag) arguments. * @return array */ private static function config_from( array $assoc ): array { if ( empty( $assoc['config'] ) ) { return array(); } $decoded = json_decode( (string) $assoc['config'], true ); if ( ! is_array( $decoded ) ) { \WP_CLI::error( '--config must be valid JSON object.' ); } return $decoded; } }