# applies exactly that bundle * * Without a connection: wp wsync export changes.zip on staging, wp wsync apply changes.zip on live. * * Exit codes: 0 done, 1 error, 2 blocked (see the plan), 3 conflicts (with --fail-on-conflict), * 4 rollback refused because objects changed since, 5 rollback incomplete. * With --format=json every command prints exactly one JSON document on stdout. */ class Cli { /** * Plugin. * * @var Wonderful_Sync_Plugin */ private $plugin; /** * Constructor. * * @param Wonderful_Sync_Plugin $plugin Plugin. */ public function __construct( Wonderful_Sync_Plugin $plugin ) { $this->plugin = $plugin; } /** * Records the current state as baseline. Run it right after cloning. * * ## OPTIONS * * --origin= * : URL of the live site this copy was cloned from. * * [--yes] * : Replace an existing baseline without asking. * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function baseline( $args, $assoc_args ) { if ( Changeset::meta() && empty( $assoc_args['yes'] ) ) { \WP_CLI::confirm( 'A baseline exists. Replace it? Changes made since then will no longer be detected.' ); } try { $meta = $this->plugin->changeset()->capture( (string) ( $assoc_args['origin'] ?? '' ) ); } catch ( \RuntimeException $e ) { \WP_CLI::error( $e->getMessage() ); } Admin::mark_dirty(); \WP_CLI::success( sprintf( 'Baseline recorded: %d objects.', $meta['count'] ) ); } /** * Shows or sets the live URL the staging copy was cloned from, without a new baseline. * * ## OPTIONS * * [] * : Live URL. * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function origin( $args, $assoc_args ) { if ( ! Changeset::meta() ) { \WP_CLI::error( 'No baseline. Run: wp wsync baseline --origin=' ); } if ( empty( $args[0] ) ) { \WP_CLI::line( (string) Changeset::meta()['origin'] ); return; } Changeset::set_origin( (string) $args[0] ); \WP_CLI::success( 'Live URL saved; the baseline is unchanged.' ); } /** * Shows where snapshots are stored and whether the web can reach them. * * ## OPTIONS * * [--move] * : Move baseline, file signatures and releases from the former folder under uploads * into the directory set with WONDERFUL_SYNC_STORAGE_DIR, then remove the former folder. * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function storage( $args, $assoc_args ) { Storage::reset_check(); $public = Storage::is_public(); $legacy = Storage::legacy_dir(); \WP_CLI::log( 'WONDERFUL_SYNC_STORAGE_DIR: ' . ( defined( 'WONDERFUL_SYNC_STORAGE_DIR' ) ? (string) WONDERFUL_SYNC_STORAGE_DIR : '(not defined)' ) ); if ( defined( 'WONDERFUL_SYNC_STORAGE_DIR' ) && '' === Storage::external_dir() ) { \WP_CLI::warning( 'The directory is not used: it does not exist, is not writable for PHP, or lies inside the web root.' ); } \WP_CLI::log( 'Working directory: ' . Storage::base() ); \WP_CLI::log( 'Baseline: ' . ( file_exists( Storage::base() . 'baseline.json' ) ? 'present' : 'missing' ) ); if ( '' !== $legacy ) { \WP_CLI::log( 'Former folder under uploads: ' . $legacy . ( file_exists( $legacy . 'baseline.json' ) ? ' (with baseline)' : '' ) ); } if ( ! empty( $assoc_args['move'] ) ) { if ( '' === $legacy ) { \WP_CLI::error( '' === Storage::external_dir() ? 'Set WONDERFUL_SYNC_STORAGE_DIR to a writable directory outside the web root first.' : 'There is no former folder under uploads.' ); } try { $moved = Storage::move_legacy(); } catch ( \RuntimeException $e ) { \WP_CLI::error( $e->getMessage() ); } \WP_CLI::success( sprintf( 'Moved %d files, kept %d that already existed. The former folder was removed.', $moved['moved'], $moved['kept'] ) ); return; } if ( false === $public ) { \WP_CLI::success( 'Private: snapshots cannot be downloaded from the web.' ); } elseif ( true === $public ) { \WP_CLI::warning( 'Public: the web server serves files from this folder (it ignores .htaccess).' ); } else { \WP_CLI::warning( 'Unknown: the check failed. ' . Storage::check_error() ); } if ( '' !== $legacy ) { \WP_CLI::warning( 'Files are still in the former folder under uploads. Run: wp wsync storage --move' ); } } /** * Shows what changed since the baseline. * * ## OPTIONS * * [--format=] * : table, json, csv, count. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function status( $args, $assoc_args ) { if ( ! Changeset::meta() ) { \WP_CLI::error( 'No baseline. Run: wp wsync baseline --origin=' ); } $rows = array(); foreach ( $this->plugin->changeset()->compute() as $c ) { $rows[] = array( 'status' => $c['status'], 'class' => $c['class'], 'key' => $c['key'], 'title' => $c['title'], 'fields' => implode( ', ', (array) $c['fields'] ), ); } \WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'status', 'class', 'key', 'title', 'fields' ) ); } /** * Lists everything on this site by sync class. * * ## OPTIONS * * [--format=] * : table, json, csv. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function inventory( $args, $assoc_args ) { $rows = array(); foreach ( ( new Inventory( $this->plugin->classifier() ) )->build() as $class => $items ) { foreach ( $items as $item ) { $rows[] = array( 'class' => $class, 'syncable' => Classifier::is_syncable( $class ) ? 'yes' : 'no', 'kind' => $item['kind'], 'name' => $item['name'], 'count' => $item['count'], 'detail' => $item['detail'], ); } } \WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'class', 'syncable', 'kind', 'name', 'count', 'detail' ) ); } /** * Writes a bundle file with the selected changes. * * ## OPTIONS * * * : Target ZIP file. * * [--keys=] * : Comma separated keys from `wp wsync status`. Default: design, layouts and content, no deletions. * * [--keys-file=] * : JSON file with an array of keys. * * [--all] * : Everything in the changeset, deletions and site settings included. * * [--format=] * : table or json. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function export( $args, $assoc_args ) { $built = $this->plugin->sync()->build( $this->selection( $assoc_args ) ); if ( ! copy( $built['file'], $args[0] ) ) { \WP_CLI::error( 'Could not write ' . $args[0] ); } wp_delete_file( $built['file'] ); if ( 'json' === ( $assoc_args['format'] ?? '' ) ) { $this->json( array( 'file' => $args[0], 'keys' => $built['keys'], 'added' => $built['added'], ) ); return; } \WP_CLI::success( sprintf( '%d objects written to %s (%d added as dependencies).', count( $built['keys'] ), $args[0], count( $built['added'] ) ) ); } /** * Shows what applying a bundle file would do on this site. Exit code 2 when blocked. * * ## OPTIONS * * * : Bundle ZIP. * * [--fail-on-conflict] * : Exit code 3 when the plan has conflicts. * * [--format=] * : table or json. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function plan( $args, $assoc_args ) { $plan = $this->plugin->sync()->plan_local( $args[0] ); $this->print_plan( $plan, $assoc_args['format'] ?? 'table' ); $this->exit_for_plan( $plan, $assoc_args ); } /** * Applies a bundle file on this site. * * ## OPTIONS * * * : Bundle ZIP. * * [--take-staging=] * : Conflicts to resolve in favour of the bundle: comma separated keys or "all". * * [--force] * : Ignore soft blockers such as an older Divi version. * * [--fail-on-partial] * : Exit code 3 when objects were kept, refused or left out. * * [--format=] * : table or json. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function apply( $args, $assoc_args ) { $sync = $this->plugin->sync(); $plan = $sync->plan_local( $args[0] ); if ( ! empty( $plan['hard'] ) || ( ! empty( $plan['blockers'] ) && empty( $assoc_args['force'] ) ) ) { $this->print_plan( $plan, $assoc_args['format'] ?? 'table' ); \WP_CLI::halt( 2 ); } $decisions = $this->decisions( $plan, (string) ( $assoc_args['take-staging'] ?? '' ) ); $release = $sync->apply_local( $args[0], $decisions, ! empty( $assoc_args['force'] ) ); $this->print_release( $release, $assoc_args['format'] ?? 'table', $plan, false ); if ( $this->partial && ! empty( $assoc_args['fail-on-partial'] ) ) { \WP_CLI::halt( 3 ); } } /** * Sends the selected changes to the connected live site. * * ## OPTIONS * * [--keys=] * : Comma separated keys. Default: design, layouts and content, no deletions. * * [--keys-file=] * : JSON file with an array of keys (for long selections). * * [--all] * : Everything in the changeset, deletions and site settings included. * * [--dry-run] * : Only show the plan from the live site. Prints a preview ID; exit code 2 when blocked. * * [--preview=] * : Apply exactly the bundle of an earlier dry run instead of building a new one. * * [--fail-on-conflict] * : With --dry-run: exit code 3 when the plan has conflicts. * * [--fail-on-partial] * : Exit code 3 when objects were kept, refused or left out. * * [--take-staging=] * : Conflicts to resolve in favour of staging: comma separated keys or "all". * * [--force] * : Ignore soft blockers such as an older Divi version on live. * * [--format=] * : table or json. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function push( $args, $assoc_args ) { $sync = $this->plugin->sync(); $format = $assoc_args['format'] ?? 'table'; if ( ! empty( $assoc_args['preview'] ) ) { $preview = get_option( 'wonderful_sync_preview_' . sanitize_key( (string) $assoc_args['preview'] ) ); if ( ! is_array( $preview ) ) { \WP_CLI::error( 'Unknown or expired preview. Run push --dry-run again.' ); } if ( (string) ( $preview['target'] ?? '' ) !== (string) Remote::settings()['url'] ) { \WP_CLI::error( 'The connection was changed after this preview. Run push --dry-run again.' ); } } else { $preview = $sync->preview( $this->selection( $assoc_args ) ); $preview['target'] = (string) Remote::settings()['url']; update_option( 'wonderful_sync_preview_' . sanitize_key( $preview['remote_id'] ), $preview, false ); } if ( ! empty( $assoc_args['dry-run'] ) ) { $this->print_plan( $preview['plan'], $format, $preview['remote_id'] ); $this->exit_for_plan( $preview['plan'], $assoc_args ); return; } if ( ! empty( $preview['plan']['hard'] ) || ( ! empty( $preview['plan']['blockers'] ) && empty( $assoc_args['force'] ) ) ) { $this->print_plan( $preview['plan'], $format, $preview['remote_id'] ); \WP_CLI::halt( 2 ); } $decisions = $this->decisions( $preview['plan'], (string) ( $assoc_args['take-staging'] ?? '' ) ); $release = $sync->apply_remote( $preview['remote_id'], $decisions, ! empty( $assoc_args['force'] ), $preview['hashes'], (string) ( $preview['plan']['digest'] ?? '' ) ); delete_option( 'wonderful_sync_preview_' . sanitize_key( $preview['remote_id'] ) ); $this->print_release( $release, $format, $preview['plan'], true ); if ( $this->partial && ! empty( $assoc_args['fail-on-partial'] ) ) { \WP_CLI::halt( 3 ); } } /** * Takes over changes made on the live site since the baseline (live → staging). * * ## OPTIONS * * [--keys=] * : Comma separated keys. Default: design, layouts and content, no deletions. * * [--all] * : Everything that changed on the live site, deletions included. * * [--dry-run] * : Only show the list and the plan; exit code 2 when blocked. * * [--take-live=] * : Conflicts to resolve in favour of the live site: comma separated keys or "all". * * [--force] * : Ignore soft blockers. * * [--format=] * : table or json. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function pull( $args, $assoc_args ) { $sync = $this->plugin->sync(); $format = $assoc_args['format'] ?? 'table'; try { $changes = $sync->pull_changes(); if ( array_key_exists( 'keys', $assoc_args ) ) { $keys = array_values( array_filter( array_map( 'trim', explode( ',', (string) $assoc_args['keys'] ) ) ) ); } else { $keys = ! empty( $assoc_args['all'] ) ? array_keys( $changes ) : Sync::default_keys( $changes ); } if ( empty( $keys ) ) { \WP_CLI::success( 'No changes on the live site to take over.' ); return; } $preview = $sync->pull_preview( $keys ); } catch ( \RuntimeException $e ) { \WP_CLI::error( $e->getMessage() ); } if ( ! empty( $assoc_args['dry-run'] ) || ! empty( $preview['plan']['hard'] ) || ( ! empty( $preview['plan']['blockers'] ) && empty( $assoc_args['force'] ) ) ) { $this->print_plan( $preview['plan'], $format, '' ); wp_delete_file( $preview['file'] ); $this->exit_for_plan( $preview['plan'], $assoc_args ); if ( empty( $assoc_args['dry-run'] ) ) { \WP_CLI::halt( 2 ); } return; } $decisions = $this->decisions( $preview['plan'], (string) ( $assoc_args['take-live'] ?? '' ) ); $release = $sync->apply_pull( $preview['file'], $decisions, ! empty( $assoc_args['force'] ), $preview['hashes'] ); Bundle::close( $preview['file'] ); wp_delete_file( $preview['file'] ); $this->print_release( $release, $format, $preview['plan'], false ); } /** * Lists releases on this site, or on the live site with --remote. * * ## OPTIONS * * [--remote] * : Ask the connected live site. * * [--format=] * : table or json. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function releases( $args, $assoc_args ) { $list = ! empty( $assoc_args['remote'] ) ? $this->plugin->sync()->remote()->releases() : Importer::releases(); foreach ( $list as $i => $row ) { $list[ $i ]['created'] = wp_date( 'Y-m-d H:i', (int) $row['created'] ); } \WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $list, array( 'id', 'created', 'status', 'count', 'source', 'user' ) ); } /** * Rolls a release back. Exit code 4 when objects changed since (use --force), 5 when incomplete. * * ## OPTIONS * * * : Release ID. * * [--remote] * : Roll back on the connected live site. * * [--force] * : Also restore objects that were changed after the release. * * [--format=] * : table or json. * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function rollback( $args, $assoc_args ) { $force = ! empty( $assoc_args['force'] ); $result = ! empty( $assoc_args['remote'] ) ? $this->plugin->sync()->remote()->rollback( $args[0], $force ) : $this->plugin->importer()->rollback( $args[0], $force ); if ( empty( $assoc_args['remote'] ) && ! empty( $result['restored'] ) ) { $this->plugin->sync()->after_local_rollback( (string) $args[0] ); } $refused = ! empty( $result['drifted'] ) && empty( $result['restored'] ); if ( 'json' === ( $assoc_args['format'] ?? '' ) ) { $this->json( $result ); } else { foreach ( (array) ( $result['warnings'] ?? array() ) as $msg ) { \WP_CLI::warning( $msg ); } if ( $refused ) { \WP_CLI::warning( 'Changed after the release, use --force to restore anyway: ' . implode( ', ', $result['drifted'] ) ); } elseif ( empty( $result['failed'] ) ) { \WP_CLI::success( sprintf( '%d objects restored.', count( $result['restored'] ) ) ); } else { \WP_CLI::warning( sprintf( '%d objects restored, %d not completely: %s', count( $result['restored'] ), count( $result['failed'] ), implode( ', ', $result['failed'] ) ) ); } } if ( $refused ) { \WP_CLI::halt( 4 ); } if ( ! empty( $result['failed'] ) ) { \WP_CLI::halt( 5 ); } } /** * Removes a transfer lock left behind by a crashed process. * * The lock frees itself two minutes after the last sign of life anyway; use this only * when you are sure no transfer is running. * * ## OPTIONS * * [--yes] * : Do not ask. * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function unlock( $args, $assoc_args ) { if ( empty( $assoc_args['yes'] ) ) { \WP_CLI::confirm( 'Remove the transfer lock? Only do this when no transfer is running.' ); } switch ( Importer::force_unlock() ) { case 'running': \WP_CLI::error( 'A transfer is still running on this site (its process holds the database lock). Nothing removed.' ); break; case 'none': \WP_CLI::success( 'There is no lock.' ); break; case 'changed': \WP_CLI::error( 'The lock changed while checking it – a new transfer may have started. Nothing removed.' ); break; default: \WP_CLI::success( 'Lock removed.' ); } } /** * Connection to the live site. * * ## OPTIONS * * * : set or info. * * [] * : Live site URL (set). * * [] * : User name (set). * * [] * : Application password (set). * * [--format=] * : table or json (info). * --- * default: table * --- * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function remote( $args, $assoc_args ) { if ( 'set' === ( $args[0] ?? '' ) ) { if ( count( $args ) < 4 ) { \WP_CLI::error( 'Usage: wp wsync remote set ' ); } Remote::save( $args[1], $args[2], $args[3] ); if ( Changeset::meta() && empty( Changeset::meta()['origin'] ) ) { Changeset::set_origin( $args[1] ); } \WP_CLI::success( 'Saved.' ); return; } $info = $this->plugin->sync()->remote()->info(); if ( 'json' === ( $assoc_args['format'] ?? '' ) ) { unset( $info['code'] ); $this->json( $info ); return; } \WP_CLI::log( sprintf( 'Live: %s, WordPress %s, Divi %s, Wonderful Sync %s', $info['home'], $info['wp'], '' !== $info['divi'] ? $info['divi'] : '-', $info['plugin'] ) ); } /** * Selection from --keys, --keys-file or --all; null means the default selection. * * @param array $assoc_args Named arguments. * @return string[]|null */ private function selection( array $assoc_args ): ?array { if ( array_key_exists( 'keys-file', $assoc_args ) ) { $raw = file_get_contents( (string) $assoc_args['keys-file'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file given on the command line. $keys = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( ! is_array( $keys ) || empty( $keys ) ) { \WP_CLI::error( '--keys-file must contain a non-empty JSON array of keys.' ); } return array_map( 'strval', $keys ); } if ( array_key_exists( 'keys', $assoc_args ) ) { $keys = array_values( array_filter( array_map( 'trim', explode( ',', (string) $assoc_args['keys'] ) ) ) ); if ( empty( $keys ) ) { \WP_CLI::error( '--keys is empty. Leave it out for the default selection.' ); } return $keys; } if ( ! empty( $assoc_args['all'] ) ) { return array_keys( $this->plugin->changeset()->compute() ); } return null; } /** * Conflict decisions from --take-staging. * * @param array $plan Plan. * @param string $value Option value. * @return array */ private function decisions( array $plan, string $value ): array { $take = 'all' === $value ? null : array_filter( array_map( 'trim', explode( ',', $value ) ) ); $out = array(); foreach ( $plan['items'] as $item ) { if ( '' !== $item['conflict'] ) { $out[ $item['key'] ] = ( null === $take || in_array( $item['key'], $take, true ) ) && '' !== $value ? 'source' : 'target'; } } return $out; } /** * Exit code of a plan: 0 fine, 2 blocked, 3 conflicts when --fail-on-conflict is set. * * @param array $plan Plan. * @param array $assoc_args Named arguments. */ private function exit_for_plan( array $plan, array $assoc_args ): void { if ( ! empty( $plan['blockers'] ) ) { \WP_CLI::halt( 2 ); } if ( ! empty( $assoc_args['fail-on-conflict'] ) && array_filter( wp_list_pluck( $plan['items'], 'conflict' ) ) ) { \WP_CLI::halt( 3 ); } } /** * Prints one JSON document. * * @param mixed $data Data. */ private function json( $data ): void { \WP_CLI::line( (string) wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); } /** * Prints a plan. * * @param array $plan Plan. * @param string $format Format. * @param string $preview Preview ID ('' for local plans). */ private function print_plan( array $plan, string $format, string $preview = '' ): void { if ( 'json' === $format ) { $this->json( array( 'preview' => $preview ) + $plan ); return; } $rows = array_map( function ( $i ) { return array( 'action' => $i['action'], 'conflict' => $i['conflict'], 'class' => $i['class'], 'key' => $i['key'], 'title' => $i['title'], 'fields' => implode( ', ', (array) ( $i['fields'] ?? array() ) ), 'note' => $i['note'], ); }, $plan['items'] ); \WP_CLI\Utils\format_items( 'table', $rows, array( 'action', 'conflict', 'class', 'key', 'title', 'fields', 'note' ) ); foreach ( (array) ( $plan['hard'] ?? array() ) as $msg ) { \WP_CLI::warning( 'CANNOT APPLY: ' . $msg ); } foreach ( array_diff( (array) $plan['blockers'], (array) ( $plan['hard'] ?? array() ) ) as $msg ) { \WP_CLI::warning( 'BLOCKER (--force to override): ' . $msg ); } foreach ( $plan['warnings'] as $msg ) { \WP_CLI::warning( $msg ); } if ( '' !== $preview ) { \WP_CLI::log( 'Preview ID: ' . $preview . ' – apply exactly this bundle with: wp wsync push --preview=' . $preview ); } } /** * Prints a release with what was applied, kept, refused and left out. * * @param array $release Release. * @param string $format Format. * @param array $plan Plan the release was made from. * @param bool $remote Whether it ran on the connected live site. */ private function print_release( array $release, string $format, array $plan, bool $remote ): void { $applied = array_flip( (array) $release['applied'] ); $result = array( 'release' => (string) $release['id'], 'applied' => array_keys( $applied ), 'kept' => array(), 'refused' => array(), 'left_out' => array(), 'warnings' => array_values( (array) ( $release['warnings'] ?? array() ) ), 'rollback' => 'wp wsync rollback ' . $release['id'] . ( $remote ? ' --remote' : '' ), ); foreach ( (array) ( $plan['items'] ?? array() ) as $item ) { if ( isset( $applied[ $item['key'] ] ) || 'skip' === $item['action'] ) { continue; } $bucket = 'refused' === $item['action'] ? 'refused' : ( '' !== $item['conflict'] ? 'kept' : 'left_out' ); $result[ $bucket ][] = $item['key']; } if ( 'json' === $format ) { $this->json( $result ); $this->partial = ! empty( $result['kept'] ) || ! empty( $result['refused'] ) || ! empty( $result['left_out'] ); return; } foreach ( $result['warnings'] as $msg ) { \WP_CLI::warning( $msg ); } \WP_CLI::success( sprintf( 'Release %s: %d applied, %d kept (conflicts), %d refused, %d left out. Roll back with: %s', $result['release'], count( $result['applied'] ), count( $result['kept'] ), count( $result['refused'] ), count( $result['left_out'] ), $result['rollback'] ) ); $this->partial = ! empty( $result['kept'] ) || ! empty( $result['refused'] ) || ! empty( $result['left_out'] ); } /** * Whether the last release left objects out (for --fail-on-partial). * * @var bool */ private $partial = false; }