'db', 'database' => 'db', 'uploads' => 'uploads', 'plugins' => 'plugins', 'themes' => 'themes', 'mu-plugins' => 'mu_plugins', 'mu_plugins' => 'mu_plugins', 'others' => 'others', ]; /** * Exports the site to a .mmig backup. * * ## OPTIONS * * [--exclude=] * : Leave out paths matching this glob (relative to wp-content). Repeat for more. * * [--skip=] * : Leave out a whole part: db, uploads, plugins, themes, mu-plugins or others. Repeat or comma-separate. * * [--replace=] * : Find and replace in the exported database, given as =. Repeat for more pairs. * * [--password[=]] * : Encrypt the backup. Give --password without a value to be asked for it. * * [--storage=] * : Where to save the backup. * --- * default: local * --- * * ## EXAMPLES * * wp mench-migrate export * wp mench-migrate export --skip=uploads --exclude='cache/*' --replace=https://old.test=https://new.test * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function export( $args, $assoc_args ) { $include = []; foreach ( self::multi( 'skip', $assoc_args ) as $value ) { foreach ( array_filter( array_map( 'trim', explode( ',', $value ) ) ) as $group ) { if ( ! isset( self::SKIP[ $group ] ) ) { /* translators: %s: group name. */ WP_CLI::error( sprintf( __( 'Unknown --skip group "%s". Use db, uploads, plugins, themes, mu-plugins or others.', 'mench-migrate' ), $group ) ); } $include[ self::SKIP[ $group ] ] = false; } } $replace = []; foreach ( self::multi( 'replace', $assoc_args ) as $pair ) { $at = strpos( $pair, '=' ); if ( false === $at || 0 === $at ) { /* translators: %s: the argument given. */ WP_CLI::error( sprintf( __( 'Use --replace== (got "%s").', 'mench-migrate' ), $pair ) ); } $replace[ substr( $pair, 0, $at ) ] = substr( $pair, $at + 1 ); } $storage = self::storage( $assoc_args['storage'] ?? 'local' ); $options = [ 'include' => $include, 'excludes' => self::multi( 'exclude', $assoc_args ), 'replace' => $replace, 'storage' => $storage->id(), ]; $password = self::password( $assoc_args, true ); if ( null !== $password ) { $options['password'] = $password; } $job = self::create( 'export', $options ); $job = self::run( $job->id, __( 'Exporting', 'mench-migrate' ) ); self::exit_unless_done( $job ); $name = (string) ( $job->data['archive'] ?? '' ); $bytes = (int) ( $job->data['bytes'] ?? 0 ); if ( 0 === $bytes && 'local' === $storage->id() && Mench_Migrate_Storage_Local::valid_name( $name ) && is_file( Mench_Migrate_Paths::backups() . '/' . $name ) ) { $bytes = (int) filesize( Mench_Migrate_Paths::backups() . '/' . $name ); } /* translators: 1: archive name, 2: size, 3: storage label. */ WP_CLI::success( sprintf( __( 'Exported %1$s (%2$s) to %3$s.', 'mench-migrate' ), $name, Mench_Migrate_Admin::size( $bytes ), $storage->label() ) ); } /** * Imports a backup into this site, replacing it. A rollback stays available afterwards. * * ## OPTIONS * * * : A backup name from `wp mench-migrate list`, or the path of a .mmig or .wpress file (copied into backups first). * * [--password[=]] * : Password of an encrypted backup. Give --password without a value to be asked for it. * * [--yes] * : Replace the site without asking after the compatibility report. * * ## EXAMPLES * * wp mench-migrate import ~/Downloads/shop-20261018.mmig --yes * wp mench-migrate import shop-20261018-120000-abc.mmig * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function import( $args, $assoc_args ) { $name = self::local_name( (string) $args[0] ); self::import_job( 'local', $name, $assoc_args ); } /** * Restores a backup held in a storage, replacing this site. * * ## OPTIONS * * * : The backup name. * * [--storage=] * : The storage holding it. * --- * default: local * --- * * [--password[=]] * : Password of an encrypted backup. Give --password without a value to be asked for it. * * [--yes] * : Replace the site without asking after the compatibility report. * * ## EXAMPLES * * wp mench-migrate restore shop-20261018-120000-abc.mmig --yes * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function restore( $args, $assoc_args ) { $name = (string) $args[0]; if ( ! Mench_Migrate_Storage_Local::valid_name( $name ) ) { WP_CLI::error( Mench_Migrate_Rest::message( 'invalid_name' ) ); } self::import_job( self::storage( $assoc_args['storage'] ?? 'local' )->id(), $name, $assoc_args ); } /** * Rolls the site back to how it was before an import. * * ## OPTIONS * * [] * : The import job. Defaults to the last import. * * [--yes] * : Roll back without asking. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function rollback( $args, $assoc_args ) { $id = self::import_id( $args ); /* translators: %s: job id. */ WP_CLI::confirm( sprintf( __( 'Roll this site back to how it was before import %s?', 'mench-migrate' ), $id ), $assoc_args ); try { Mench_Migrate_Job_Runner::rollback( $id ); } catch ( Mench_Migrate_Exception $e ) { self::error_from( $e ); } WP_CLI::success( __( 'Rolled back. The site is as it was before the import.', 'mench-migrate' ) ); } /** * Keeps the imported site and discards its rollback. * * ## OPTIONS * * [] * : The import job. Defaults to the last import. * * [--yes] * : Discard the rollback without asking. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function keep( $args, $assoc_args ) { $id = self::import_id( $args ); /* translators: %s: job id. */ WP_CLI::confirm( sprintf( __( 'Keep this site and discard the rollback of import %s? It cannot be undone.', 'mench-migrate' ), $id ), $assoc_args ); try { Mench_Migrate_Job_Runner::keep( $id ); } catch ( Mench_Migrate_Exception $e ) { self::error_from( $e ); } WP_CLI::success( __( 'Kept. The rollback was discarded.', 'mench-migrate' ) ); } /** * Lists backups. * * ## OPTIONS * * [--storage=] * : Only this storage. Defaults to every configured storage. * * [--format=] * : Output format. * --- * default: table * options: * - table * - json * --- * * @subcommand list * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function list_( $args, $assoc_args ) { $format = $assoc_args['format'] ?? 'table'; $storages = isset( $assoc_args['storage'] ) ? [ self::storage( (string) $assoc_args['storage'] ) ] : array_filter( Mench_Migrate_Storages::all(), static fn( $s ) => $s->is_configured() ); $rows = []; foreach ( $storages as $storage ) { try { $list = $storage->list(); } catch ( Throwable $e ) { /* translators: 1: storage label, 2: error. */ WP_CLI::warning( sprintf( __( 'Could not list %1$s: %2$s', 'mench-migrate' ), $storage->label(), $e->getMessage() ) ); continue; } foreach ( $list as $b ) { $encrypted = ! empty( $b['encrypted'] ); $rows[] = [ 'name' => (string) ( $b['name'] ?? '' ), 'size' => Mench_Migrate_Admin::size( (int) ( $b['size'] ?? 0 ) ), 'modified' => gmdate( 'c', (int) ( $b['modified'] ?? 0 ) ), 'encrypted' => 'json' === $format ? $encrypted : ( $encrypted ? 'yes' : 'no' ), 'type' => (string) ( $b['type'] ?? '' ), 'storage' => $storage->id(), ]; } } WP_CLI\Utils\format_items( $format, $rows, [ 'name', 'size', 'modified', 'encrypted', 'type', 'storage' ] ); } /** * Deletes a backup. * * ## OPTIONS * * * : The backup name. * * [--storage=] * : The storage holding it. * --- * default: local * --- * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function delete( $args, $assoc_args ) { $storage = self::storage( $assoc_args['storage'] ?? 'local' ); $res = $storage->delete( (string) $args[0] ); if ( is_wp_error( $res ) ) { WP_CLI::error( $res->get_error_message() ); } /* translators: %s: archive name. */ WP_CLI::success( sprintf( __( 'Deleted %s.', 'mench-migrate' ), $args[0] ) ); } // --- internals ---------------------------------------------------------------------------------------------- /** Starts an import of $name from $storage, prints the report, confirms, runs it and reports the result. */ private static function import_job( string $storage, string $name, array $assoc_args ): void { $options = [ 'storage' => $storage, 'archive' => $name, 'confirm' => false, 'only' => [], 'initiator_login' => (string) wp_get_current_user()->user_login, ]; $password = self::password( $assoc_args, false ); if ( null === $password && 'local' === $storage && Mench_Migrate_Archive_Reader::peek( Mench_Migrate_Paths::backups() . '/' . $name )['encrypted'] ) { if ( ! function_exists( 'stream_isatty' ) || ! stream_isatty( STDIN ) ) { WP_CLI::error( __( 'This backup is encrypted. Give its password with --password.', 'mench-migrate' ) ); } $password = self::password( [ 'password' => true ], false ); } if ( null !== $password ) { $options['password'] = $password; } $job = self::create( 'import', $options ); $job = self::run( $job->id, __( 'Checking', 'mench-migrate' ) ); if ( 'awaiting_confirm' === $job->status ) { self::print_report( (array) ( $job->data['confirm_report'] ?? [] ) ); $pending = $job->id; register_shutdown_function( static function () use ( &$pending ) { if ( null !== $pending ) { // The owner answered no: WP_CLI::confirm() exits. Mench_Migrate_Job_Runner::cancel( $pending ); } } ); WP_CLI::confirm( __( 'Replace this site with the backup? A rollback stays available afterwards.', 'mench-migrate' ), $assoc_args ); $pending = null; try { Mench_Migrate_Job_Runner::confirm( $job->id, [ 'confirmed' => true ] ); } catch ( Mench_Migrate_Exception $e ) { // export_running: a backup is running on this site. Mench_Migrate_Job_Runner::cancel( $job->id ); self::error_from( $e ); } $job = self::run( $job->id, __( 'Importing', 'mench-migrate' ) ); } if ( 'failed' === $job->status && ! empty( $job->data['report']['blocking'] ) ) { self::print_report( (array) $job->data['report'] ); } self::exit_unless_done( $job ); WP_CLI::success( sprintf( /* translators: %s: archive name. */ __( 'Imported %s.', 'mench-migrate' ), $name ) ); if ( Mench_Migrate_Job_Runner::has_snapshot( $job->id ) ) { $expires = ( new Mench_Migrate_Snapshot( $job->id ) )->expires_at(); /* translators: 1: date and time (UTC), 2: job id. */ WP_CLI::log( sprintf( __( 'Rollback available until %1$s UTC: wp mench-migrate rollback %2$s (or keep the site: wp mench-migrate keep %2$s).', 'mench-migrate' ), gmdate( 'Y-m-d H:i', $expires ), $job->id ) ); } WP_CLI::log( __( 'Note: the users now come from the backup. Log in with the source site\'s admin credentials.', 'mench-migrate' ) ); } /** A backup name in backups/: the argument itself, or a file copied in from the path it names. */ private static function local_name( string $arg ): string { $backups = Mench_Migrate_Paths::backups(); if ( Mench_Migrate_Storage_Local::valid_name( $arg ) && is_file( $backups . '/' . $arg ) && ! is_link( $backups . '/' . $arg ) ) { return $arg; } $path = realpath( $arg ); $ext = strtolower( (string) pathinfo( $arg, PATHINFO_EXTENSION ) ); if ( false === $path || ! is_file( $path ) || ! is_readable( $path ) ) { /* translators: %s: argument. */ WP_CLI::error( sprintf( __( '"%s" is neither a backup name nor a readable file.', 'mench-migrate' ), $arg ) ); } if ( ! in_array( $ext, [ 'mmig', 'wpress' ], true ) ) { WP_CLI::error( __( 'Only .mmig and .wpress files can be imported.', 'mench-migrate' ) ); } $valid = 'wpress' === $ext ? Mench_Migrate_Wpress_Reader::looks_valid( $path ) : Mench_Migrate_Archive_Reader::peek( $path )['valid']; if ( ! $valid ) { WP_CLI::error( Mench_Migrate_Rest::message( 'invalid_archive' ) ); } return self::copy_in( $path, $ext ); } /** Copies $path into backups/ through Storage_Local::upload under a temporary name, then claims a free name. */ private static function copy_in( string $path, string $ext ): string { $local = new Mench_Migrate_Storage_Local(); $backups = Mench_Migrate_Paths::backups(); do { $tmp = 'cli-copy-' . Mench_Migrate_Paths::random_suffix( 12 ) . '.' . $ext; } while ( file_exists( $backups . '/' . $tmp ) ); $size = (int) filesize( $path ); $bar = WP_CLI\Utils\make_progress_bar( __( 'Copying into backups', 'mench-migrate' ), 100 ); $shown = 0; $cursor = []; try { do { $t = new Mench_Migrate_Transfer( 'cli', $cursor, $size, microtime( true ) + 20, static function ( int $done, int $total ) use ( $bar, &$shown ) { $shown = self::advance( $bar, $shown, $total > 0 ? $done / $total * 100 : 100 ); } ); $res = $local->upload( $path, $tmp, $t ); $cursor = $res->cursor(); } while ( 'more' === $res->kind() ); } catch ( Mench_Migrate_Exception $e ) { wp_delete_file( $backups . '/' . $tmp ); self::error_from( $e ); } $bar->finish(); $base = (string) preg_replace( '/[^A-Za-z0-9._-]+/', '-', basename( $path ) ); $base = ltrim( $base, '.-' ); if ( ! Mench_Migrate_Storage_Local::valid_name( $base ) ) { $base = 'import-' . gmdate( 'Ymd-His' ) . '.' . $ext; } $name = Mench_Migrate_Rest_Uploads::claim( $backups . '/' . $tmp, $backups, $base ); if ( null === $name ) { wp_delete_file( $backups . '/' . $tmp ); WP_CLI::error( Mench_Migrate_Rest::message( 'write_failed' ) ); } /* translators: %s: archive name. */ WP_CLI::log( sprintf( __( 'Copied into backups as %s.', 'mench-migrate' ), $name ) ); return $name; } /** Runs a job to its next stop with a 100-tick progress bar. */ private static function run( string $id, string $label ): Mench_Migrate_Job { $bar = WP_CLI\Utils\make_progress_bar( $label, 100 ); $shown = 0; $stage = ''; $job = Mench_Migrate_Job_Runner::run_to_end( $id, static function ( Mench_Migrate_Job $job ) use ( $bar, &$shown, &$stage ) { if ( $job->stage !== $stage ) { $stage = $job->stage; WP_CLI::debug( $stage, 'mench-migrate' ); } $shown = self::advance( $bar, $shown, $job->progress ); } ); $bar->finish(); return $job; } /** Ticks $bar up to $percent (never back); returns the new position. */ private static function advance( $bar, int $shown, float $percent ): int { $to = (int) min( 100, max( 0, floor( $percent ) ) ); if ( $to > $shown ) { $bar->tick( $to - $shown ); return $to; } return $shown; } /** Exits 1 with the job's error unless it finished. */ private static function exit_unless_done( Mench_Migrate_Job $job ): void { if ( 'done' === $job->status ) { return; } $code = is_array( $job->error ) ? Mench_Migrate_Rest::code_of( (string) ( $job->error['code'] ?? '' ) ) : ''; if ( '' !== $code ) { $msg = $code . ': ' . Mench_Migrate_Rest::message( $code ); } elseif ( 'rolled_back' === $job->status ) { $msg = __( 'The import failed and the site was rolled back.', 'mench-migrate' ); } else { /* translators: %s: job status. */ $msg = sprintf( __( 'The job ended as %s.', 'mench-migrate' ), $job->status ); } if ( 'rolled_back' === $job->status && '' !== $code ) { $msg .= ' ' . __( 'The site was rolled back.', 'mench-migrate' ); } /* translators: 1: job id, 2: message. */ WP_CLI::error( sprintf( __( 'Job %1$s: %2$s', 'mench-migrate' ), $job->id, $msg ) ); } /** Prints a preflight report; its messages are HTML-safe strings, made plain for the terminal. */ private static function print_report( array $report ): void { $plain = static fn( $m ): string => html_entity_decode( wp_strip_all_tags( (string) $m ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $sets = [ 'blocking' => __( 'Blocking (the import cannot go ahead):', 'mench-migrate' ), 'warnings' => __( 'Warnings:', 'mench-migrate' ), ]; $any = false; foreach ( $sets as $key => $heading ) { $items = array_filter( (array) ( $report[ $key ] ?? [] ), 'is_array' ); if ( ! $items ) { continue; } $any = true; WP_CLI::log( $heading ); foreach ( $items as $item ) { WP_CLI::log( ' - ' . $plain( $item['message'] ?? '' ) . ' [' . $plain( $item['code'] ?? '' ) . ']' ); } } if ( ! $any ) { WP_CLI::log( __( 'Compatibility check: no problems found.', 'mench-migrate' ) ); } } /** Job::create, with its exceptions turned into an error exit. */ private static function create( string $type, array $options ): Mench_Migrate_Job { try { return Mench_Migrate_Job::create( $type, $options ); } catch ( Mench_Migrate_Exception $e ) { self::error_from( $e ); } } /** Exits 1 with the exception's code and message. */ private static function error_from( Throwable $e ): never { $code = $e instanceof Mench_Migrate_Exception ? Mench_Migrate_Rest::code_of( $e->getMessage() ) : 'unexpected_error'; WP_CLI::error( $code . ': ' . Mench_Migrate_Rest::message( $code ) ); } private static function storage( string $id ): Mench_Migrate_Storage { try { return Mench_Migrate_Storages::get( $id ); } catch ( Mench_Migrate_Exception $e ) { /* translators: %s: storage id. */ WP_CLI::error( sprintf( __( 'Unknown storage "%s".', 'mench-migrate' ), $id ) ); } } /** The job id given, or the last import's. */ private static function import_id( array $args ): string { if ( isset( $args[0] ) && '' !== $args[0] ) { return (string) $args[0]; } $last = get_option( Mench_Migrate_Step_Import_Finalize::LAST_IMPORT ); if ( ! is_array( $last ) || empty( $last['job_id'] ) || ! is_string( $last['job_id'] ) ) { WP_CLI::error( __( 'There is no recent import. Give the job id.', 'mench-migrate' ) ); } return $last['job_id']; } /** --password=

, or --password alone to be asked without echo; null when not given. */ private static function password( array $assoc_args, bool $twice ): ?string { if ( ! array_key_exists( 'password', $assoc_args ) ) { return null; } $pw = $assoc_args['password']; if ( true === $pw ) { $pw = self::prompt_hidden( __( 'Password: ', 'mench-migrate' ) ); if ( $twice && self::prompt_hidden( __( 'Repeat the password: ', 'mench-migrate' ) ) !== $pw ) { WP_CLI::error( __( 'The passwords do not match.', 'mench-migrate' ) ); } } $pw = (string) $pw; if ( '' === $pw ) { WP_CLI::error( __( 'The password is empty.', 'mench-migrate' ) ); } return $pw; } private static function prompt_hidden( string $label ): string { $tty = function_exists( 'stream_isatty' ) && stream_isatty( STDIN ); fwrite( STDERR, $label ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- WP-CLI password prompt on the terminal's STDERR, not a file. $tty && @shell_exec( 'stty -echo 2>/dev/null' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors $line = fgets( STDIN ); $tty && @shell_exec( 'stty echo 2>/dev/null' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors fwrite( STDERR, "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- WP-CLI password prompt on the terminal's STDERR, not a file. return false === $line ? '' : rtrim( $line, "\r\n" ); } /** * Every value of a repeatable --key=value. WP-CLI keeps only the last one in $assoc_args, so the command line is * read again; a call from WP_CLI::runcommand (no matching argv) falls back to that last value. * * @return string[] */ private static function multi( string $key, array $assoc_args ): array { $values = []; $argv = isset( $GLOBALS['argv'] ) && is_array( $GLOBALS['argv'] ) ? array_slice( $GLOBALS['argv'], 1 ) : []; if ( in_array( 'mench-migrate', $argv, true ) ) { foreach ( WP_CLI\Configurator::extract_assoc( $argv )[1] as [ $k, $v ] ) { if ( $k === $key && is_string( $v ) ) { $values[] = $v; } } } if ( ! $values && isset( $assoc_args[ $key ] ) && is_string( $assoc_args[ $key ] ) ) { $values[] = $assoc_args[ $key ]; } return array_values( array_filter( $values, static fn( $v ) => '' !== trim( $v ) ) ); } }