— Toggle TMDO_Hook_Bus_Bridge. * wp wpdo mode-audit — Per-module state + shadow flags. * wp wpdo mode-set — Set FSM state. * wp wpdo shadow-enable — Enable shadow_read_only sub-flag. * wp wpdo shadow-disable — Disable shadow_read_only. * wp wpdo conflict-scan — Run conflict monitor + emit JSON. * wp wpdo lint --plugin= — Anti-EAV lint (Part C.2). * * Kept in a separate class so v1.x CLI commands stay untouched. * * @package WP_Data_Optimizer * @since 2.0.0 */ if ( ! defined( 'ABSPATH' ) ) { exit; } // phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_CLI callbacks must accept ($args, $assoc_args) by signature; many subcommands ignore them. file_get_contents() reads local PHP files only — wp_remote_get N/A. if ( ! class_exists( 'WP_CLI' ) ) { return; } /** * v2.0.0 CLI subcommands. Loaded only in WP_CLI context. */ class TMDO_CLI_V2 { /** * Show Hook Bus status, conflict count, and dual_write progress per entity group. * * ## OPTIONS * * [--format=] * : Output format for dual_write progress table. Options: table, json. Default: table. * * ## EXAMPLES * * wp wpdo bridge-status * wp wpdo bridge-status --format=json */ public function bridge_status( $args, $assoc_args ): void { global $wpdb; // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $enabled = TMDO_Hook_Bus_Bridge::is_enabled(); $summary = TMDO_Conflict_Monitor::get_summary(); $status = $enabled ? 'enabled' : 'disabled'; WP_CLI::log( "Hook Bus: {$status}" ); WP_CLI::log( "Conflicts (total / hook_overlap / uaepg_overlap): {$summary['total']} / {$summary['hook_overlap']} / {$summary['uaepg_overlap']}" ); if ( $summary['total'] > 0 ) { WP_CLI::warning( 'Run `wp wpdo conflict-scan` for full report.' ); } // dual_write progress: flat table rows vs EAV source rows. $modes = TMDO_Mode_Manager::all(); $rows = array(); // EAV source table per entity type. $eav_tables = array( 'post' => $wpdb->postmeta, 'user' => $wpdb->usermeta, 'term' => $wpdb->termmeta, 'comment' => $wpdb->commentmeta, ); foreach ( $modes as $entity_type => $mode ) { if ( 'disabled' === $mode ) { continue; } $groups = TMDO_Entity_Registry::get_groups_for_type( $entity_type ); if ( empty( $groups ) ) { continue; } foreach ( $groups as $group_name ) { $flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); // Check flat table existence. $flat_exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table ) ); $flat_count = $flat_exists // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) : 0; // EAV source: count distinct entity IDs that have at least one managed key. $managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name ); $eav_table = $eav_tables[ $entity_type ] ?? ''; $eav_count = 0; if ( '' !== $eav_table && ! empty( $managed_keys ) ) { $id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id"; $placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) ); // $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared $sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})"; $eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } $pct = ( $eav_count > 0 ) ? round( $flat_count / $eav_count * 100, 1 ) : ( $flat_count > 0 ? 100.0 : 0.0 ); $rows[] = array( 'entity' => $entity_type, 'group' => $group_name, 'mode' => $mode, 'flat_rows' => $flat_count, 'eav_ids' => $eav_count, 'progress_%' => $pct . '%', ); } } if ( ! empty( $rows ) ) { WP_CLI::log( '' ); $format = $assoc_args['format'] ?? 'table'; \WP_CLI\Utils\format_items( $format, $rows, array( 'entity', 'group', 'mode', 'flat_rows', 'eav_ids', 'progress_%' ) ); } } /** * Toggle TMDO_Hook_Bus_Bridge feature flag. * * ## OPTIONS * * * : on | off * * ## EXAMPLES * * wp wpdo bridge-set on */ public function bridge_set( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $state = strtolower( (string) ( $args[0] ?? '' ) ); if ( 'on' !== $state && 'off' !== $state ) { WP_CLI::error( 'Usage: wp wpdo bridge-set ' ); } update_option( TMDO_Hook_Bus_Bridge::OPTION, 'on' === $state ? '1' : '0' ); TMDO_Hook_Bus_Bridge::reset_cache(); WP_CLI::success( "Hook Bus: {$state}" ); } /** * Audit per-module state + shadow_read flags. * * ## EXAMPLES * * wp wpdo mode-audit * wp wpdo mode-audit --format=json * * @when after_wp_load */ public function mode_audit( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $states = TMDO_Feature_Flags::all(); $shadow = TMDO_Feature_Flags::all_shadow(); $rows = array(); foreach ( $states as $module => $state ) { $rows[] = array( 'module' => $module, 'state' => $state, 'shadow_read' => empty( $shadow[ $module ] ) ? 'no' : 'yes', ); } $format = $assoc_args['format'] ?? 'table'; \WP_CLI\Utils\format_items( $format, $rows, array( 'module', 'state', 'shadow_read' ) ); } /** * Set FSM state for a module. * * ## OPTIONS * * * : Module name (e.g. hot_hp_listing) * * * : One of idle, dual_write, backfill, verify, cutover, cleanup, complete * * ## EXAMPLES * * wp wpdo mode-set hot_hp_listing verify */ public function mode_set( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $module = (string) ( $args[0] ?? '' ); $state = (string) ( $args[1] ?? '' ); if ( '' === $module || ! in_array( $state, TMDO_Feature_Flags::VALID_STATES, true ) ) { WP_CLI::error( 'Usage: wp wpdo mode-set — state must be one of: ' . implode( ', ', TMDO_Feature_Flags::VALID_STATES ) ); } $ok = TMDO_Feature_Flags::set( $module, $state ); if ( ! $ok ) { WP_CLI::error( 'Failed to update state' ); } WP_CLI::success( "{$module} → {$state}" ); } /** * Enable shadow_read_only sub-flag (only effective in verify state). */ public function shadow_enable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $module = (string) ( $args[0] ?? '' ); if ( '' === $module ) { WP_CLI::error( 'Usage: wp wpdo shadow-enable ' ); } TMDO_Feature_Flags::enable_shadow_read( $module ); $state = TMDO_Feature_Flags::get( $module ); WP_CLI::success( "shadow_read enabled for {$module} (current state: {$state})" ); if ( 'verify' !== $state ) { WP_CLI::warning( 'shadow_read only takes effect when state == verify' ); } } /** * Disable shadow_read_only sub-flag. */ public function shadow_disable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $module = (string) ( $args[0] ?? '' ); if ( '' === $module ) { WP_CLI::error( 'Usage: wp wpdo shadow-disable ' ); } TMDO_Feature_Flags::disable_shadow_read( $module ); WP_CLI::success( "shadow_read disabled for {$module}" ); } /** * Run conflict monitor scan + emit findings. */ public function conflict_scan( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed TMDO_Conflict_Monitor::reset_cache(); $conflicts = TMDO_Conflict_Monitor::scan(); if ( empty( $conflicts ) ) { WP_CLI::success( '0 conflicts detected.' ); return; } WP_CLI::warning( count( $conflicts ) . ' conflict(s) detected:' ); // Normalise heterogenous finding shapes to a uniform 6-column row so // format_items() doesn't blow up on optional keys. $rows = array_map( static fn( array $f ) => array( 'type' => $f['type'] ?? '', 'hook' => $f['hook'] ?? '', 'priority' => isset( $f['priority'] ) ? (string) $f['priority'] : '', 'callback' => $f['callback'] ?? '', 'entity_type' => $f['entity_type'] ?? '', 'meta_key' => $f['meta_key'] ?? '', ), $conflicts ); \WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'type', 'hook', 'priority', 'callback', 'entity_type', 'meta_key' ) ); } /** * Anti-EAV strict lint for a partner plugin. * * Scans the target plugin / theme directory for: * 1. Direct SELECT FROM wp_postmeta / wp_usermeta / wp_termmeta * 2. update_post_meta() on fields registered to WPDO * 3. autoload=yes wp_options exceeding the per-plugin cap (default 30) * 4. meta_query with ≥3 conditions but no wpdo_register_fields * * Returns non-zero exit when --strict is set and findings exist (CI gate). * * ## OPTIONS * * --plugin= * : Absolute path to plugin directory. * * [--strict] * : Exit non-zero on any finding. * * [--max-autoload=] * : Soft cap on autoload=yes options per plugin (default 30). * * ## EXAMPLES * * wp wpdo lint --plugin=/var/www/.../2meet-infocards --strict */ public function lint( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $plugin_path = (string) ( $assoc_args['plugin'] ?? '' ); $strict = ! empty( $assoc_args['strict'] ); $max_autoload = (int) ( $assoc_args['max-autoload'] ?? 30 ); if ( '' === $plugin_path || ! is_dir( $plugin_path ) ) { WP_CLI::error( '--plugin= required and must be a directory' ); } $findings = self::lint_directory( $plugin_path, $max_autoload ); if ( empty( $findings ) ) { WP_CLI::success( "Anti-EAV lint passed for {$plugin_path}" ); return; } WP_CLI::warning( count( $findings ) . ' Anti-EAV violation(s) in ' . $plugin_path ); foreach ( $findings as $f ) { WP_CLI::log( sprintf( ' [%s] %s:%d — %s', $f['rule'], $f['file'], $f['line'], $f['message'] ) ); } if ( $strict ) { WP_CLI::error( 'Lint failed — strict mode enabled' ); } } /** * Walk a directory tree and apply Anti-EAV lint rules to every PHP file. * * Static so it can be unit-tested without WP_CLI runtime. * * @param string $plugin_path Absolute directory path. * @param int $max_autoload Per-plugin autoload cap. * @return array */ public static function lint_directory( string $plugin_path, int $max_autoload = 30 ): array { $findings = array(); $plugin_path = rtrim( $plugin_path, '/' ); $skip_dirs = array( 'vendor', 'node_modules', 'tests', '.git', '.github' ); $it = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $plugin_path, FilesystemIterator::SKIP_DOTS ), static function ( $current ) use ( $skip_dirs ) { if ( $current->isDir() && in_array( $current->getFilename(), $skip_dirs, true ) ) { return false; } return true; } ) ); // Patterns: regex => { rule, message }. // Match any 'postmeta' / 'usermeta' / etc. token after SELECT ... FROM, // regardless of how the table prefix is constructed (literal, $wpdb->prefix // concatenation, {$wpdb->prefix} interpolation, sprintf, etc.). $patterns = array( '/\bSELECT\b[^;]*\bFROM\s+\S*postmeta/i' => array( 'rule' => 'no-direct-postmeta-select', 'message' => 'Direct SELECT FROM postmeta — use TMDO_API::query() or TMDO_API::get_field()', ), '/\bSELECT\b[^;]*\bFROM\s+\S*usermeta/i' => array( 'rule' => 'no-direct-usermeta-select', 'message' => 'Direct SELECT FROM usermeta — use TMDO_API::get_entity()', ), '/\bSELECT\b[^;]*\bFROM\s+\S*termmeta/i' => array( 'rule' => 'no-direct-termmeta-select', 'message' => 'Direct SELECT FROM termmeta — use TMDO_API::get_entity()', ), '/\bSELECT\b[^;]*\bFROM\s+\S*commentmeta/i' => array( 'rule' => 'no-direct-commentmeta-select', 'message' => 'Direct SELECT FROM commentmeta — use TMDO_API::get_entity()', ), "/'autoload'\s*=>\s*'yes'/" => array( 'rule' => 'autoload-yes', 'message' => 'autoload=yes — keep per-plugin total ≤ ' . $max_autoload, ), ); foreach ( $it as $file ) { if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) { continue; } // Skip files that explicitly opt out. $source = (string) file_get_contents( $file->getPathname() ); if ( str_contains( $source, 'phpcs:ignore WPDO.AntiEAV' ) ) { continue; } $lines = explode( "\n", $source ); foreach ( $lines as $i => $line ) { // Skip lines marked with phpcs:ignore WPDO.AntiEAV. on the same line. if ( str_contains( $line, 'phpcs:ignore WPDO.AntiEAV' ) ) { continue; } foreach ( $patterns as $regex => $meta ) { if ( preg_match( $regex, $line ) ) { $findings[] = array( 'rule' => $meta['rule'], 'file' => $file->getPathname(), 'line' => $i + 1, 'message' => $meta['message'], ); } } } } return $findings; } // ─── snapshot subcommands (v2.2.0 M1) ───────────────────────────────── /** * Create a snapshot. * * ## OPTIONS * * [--trigger=] * : One of manual|pre_fsm_transition|pre_v2_upgrade|scheduled|pre_uninstall. * --- * default: manual * --- * * [--scope-tables=] * : Comma-separated table list to dump. Empty = all WPDO tables. * * [--scope-entities=] * : Comma-separated entity list (post,user,term,comment) — adds wp_*meta to dump. * * [--notes=] * : Free-form note for the catalog. * * [--retention-days=] * : Snapshot expiry. Default 30. 0 disables. * * ## EXAMPLES * * wp wpdo snapshot create --trigger=manual --notes="before reviews backfill" * wp wpdo snapshot create --scope-tables=wp_wpdo_warm,wp_wpdo_archive */ public function snapshot_create( $args, $assoc_args ): void { $trigger = (string) ( $assoc_args['trigger'] ?? 'manual' ); $scope = array( 'tables' => self::csv_to_array( (string) ( $assoc_args['scope-tables'] ?? '' ) ), 'entities' => self::csv_to_array( (string) ( $assoc_args['scope-entities'] ?? '' ) ), ); $opts = array( 'notes' => (string) ( $assoc_args['notes'] ?? '' ), 'retention_days' => isset( $assoc_args['retention-days'] ) ? (int) $assoc_args['retention-days'] : TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS, ); $result = TMDO_Snapshot_Manager::create( $trigger, $scope, $opts ); if ( empty( $result['ok'] ) ) { WP_CLI::error( 'snapshot create failed: ' . ( $result['error'] ?? 'unknown' ) . ( isset( $result['message'] ) ? ' — ' . $result['message'] : '' ) ); } WP_CLI::success( sprintf( 'snapshot %s created (%d bytes, %d rows, storage=%s)', $result['snapshot_id'], $result['size_bytes'], $result['row_count'], $result['storage'] ) ); } /** * List recent snapshots. * * ## OPTIONS * * [--trigger=] * : Filter by trigger type. * * [--limit=] * : Default 50. * * [--format=] * : table|json|csv|yaml. Default table. * * ## EXAMPLES * * wp wpdo snapshot list * wp wpdo snapshot list --trigger=pre_fsm_transition --format=json */ public function snapshot_list( $args, $assoc_args ): void { $limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 50; $trigger = isset( $assoc_args['trigger'] ) ? (string) $assoc_args['trigger'] : null; $rows = TMDO_Snapshot_Manager::list_recent( $limit, $trigger ); $format = (string) ( $assoc_args['format'] ?? 'table' ); $display_keys = array( 'snapshot_id', 'trigger_type', 'size_bytes', 'row_count', 'storage', 'created_at', 'expires_at' ); \WP_CLI\Utils\format_items( $format, $rows, $display_keys ); } /** * Restore a snapshot. Default is dry-run (preview only). * * ## OPTIONS * * * : Snapshot ULID (e.g. wpdo_xyz_abc). * * [--apply] * : Actually run the restore (DELETE + INSERT). Without this flag, only preview. * * ## EXAMPLES * * wp wpdo snapshot restore wpdo_xyz_abc # preview * wp wpdo snapshot restore wpdo_xyz_abc --apply # destructive! */ public function snapshot_restore( $args, $assoc_args ): void { $snapshot_id = (string) ( $args[0] ?? '' ); if ( '' === $snapshot_id ) { WP_CLI::error( 'Usage: wp wpdo snapshot restore [--apply]' ); } $apply = isset( $assoc_args['apply'] ); $result = TMDO_Snapshot_Manager::restore( $snapshot_id, ! $apply ); if ( empty( $result['ok'] ) ) { WP_CLI::error( 'restore failed: ' . ( $result['error'] ?? 'unknown' ) . ( isset( $result['message'] ) ? ' — ' . $result['message'] : '' ) ); } if ( ! $apply ) { $preview = $result['preview']; WP_CLI::log( sprintf( 'PREVIEW (dry-run): %d statements, %d total rows, %d bytes', $preview['statements'], $preview['total_rows'], $preview['sql_bytes'] ) ); foreach ( $preview['tables'] as $t => $n ) { WP_CLI::log( " {$t}: {$n} rows" ); } WP_CLI::warning( 'No changes applied. Re-run with --apply to actually restore.' ); return; } $applied = $result['restored']; WP_CLI::success( sprintf( 'Restored %d statements, %d rows across %d tables', $applied['statements_run'], $applied['rows_restored'], count( $applied['tables'] ) ) ); } /** * Prune expired or over-cap snapshots. * * ## OPTIONS * * [--days=] * : Older-than threshold (informational; actual TTL stored in row). * --- * default: 30 * --- * * [--size-cap-mb=] * : Backup directory cap (MB). 0 disables. * --- * default: 1024 * --- * * ## EXAMPLES * * wp wpdo snapshot prune * wp wpdo snapshot prune --size-cap-mb=2048 */ public function snapshot_prune( $args, $assoc_args ): void { $days = isset( $assoc_args['days'] ) ? (int) $assoc_args['days'] : TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS; $capmb = isset( $assoc_args['size-cap-mb'] ) ? (int) $assoc_args['size-cap-mb'] : 1024; $cap = $capmb * 1024 * 1024; $res = TMDO_Snapshot_Manager::prune( $days, $cap ); WP_CLI::log( sprintf( 'Pruned %d (TTL=%d, sizecap=%d), freed %s, errors=%d', $res['pruned'], $res['ttl_pruned'] ?? 0, $res['sizecap_pruned'] ?? 0, size_format( (int) $res['freed_bytes'], 2 ), count( $res['errors'] ) ) ); if ( ! empty( $res['errors'] ) ) { foreach ( $res['errors'] as $err ) { WP_CLI::warning( $err ); } } } /** * Verify a snapshot's sha256 + readability. * * ## OPTIONS * * * : Snapshot ULID. */ public function snapshot_verify( $args, $assoc_args ): void { $snapshot_id = (string) ( $args[0] ?? '' ); if ( '' === $snapshot_id ) { WP_CLI::error( 'Usage: wp wpdo snapshot verify ' ); } $result = TMDO_Snapshot_Manager::verify( $snapshot_id ); if ( empty( $result['ok'] ) ) { WP_CLI::error( 'verify failed: ' . ( $result['error'] ?? 'unknown' ) ); } WP_CLI::success( sprintf( 'OK — sha256=%s size=%s storage=%s', $result['sha256_ok'] ? '✓' : '✗', $result['size_match'] ? '✓' : '✗', $result['storage'] ) ); } /** * Helper: split comma-separated input into a clean array. * * @param string $csv Comma-separated input. * @return array */ private static function csv_to_array( string $csv ): array { if ( '' === $csv ) { return array(); } return array_values( array_filter( array_map( 'trim', explode( ',', $csv ) ), 'strlen' ) ); } // ── crypto-status / crypto-migrate (v2.15.0) ────────────────────────────── /** * Show ciphertext format breakdown for `wpdo_*` options. * * Counts wp_options entries whose option_name starts with `wpdo_` and * classifies each by storage format: v2 (AES-256-GCM, current), v1 * (AES-256-CBC, legacy), plaintext, or empty. * * Use this to verify the v1→v2 migration ran successfully (expect * `v1=0, v2>=count(secrets)`). * * ## OPTIONS * * [--prefix=] * : Option name prefix to scan. Default: wpdo_. * * ## EXAMPLES * * wp wpdo crypto-status * wp wpdo crypto-status --prefix=wpdo_ * * @param array $args Positional arguments (unused). * @param array $assoc_args Named arguments. * @return void */ public function crypto_status( $args, $assoc_args ): void { unset( $args ); if ( ! class_exists( 'TMDO_Crypto' ) ) { WP_CLI::error( 'TMDO_Crypto class not loaded.' ); } global $wpdb; $prefix = (string) ( $assoc_args['prefix'] ?? 'wpdo_' ); $option_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name ASC", $wpdb->esc_like( $prefix ) . '%' ) ); $counts = array( 'v2' => 0, 'v1' => 0, 'plaintext' => 0, 'empty' => 0, ); $rows = array(); foreach ( (array) $option_names as $name ) { $fmt = TMDO_Crypto::format_version( $name ); ++$counts[ $fmt ]; $rows[] = array( 'option_name' => $name, 'format' => $fmt, ); } WP_CLI\Utils\format_items( 'table', $rows, array( 'option_name', 'format' ) ); WP_CLI::log( '' ); WP_CLI::log( sprintf( 'Total scanned: %d', count( $rows ) ) ); WP_CLI::log( sprintf( ' v2 (GCM): %d', $counts['v2'] ) ); WP_CLI::log( sprintf( ' v1 (CBC): %d', $counts['v1'] ) ); WP_CLI::log( sprintf( ' plaintext: %d', $counts['plaintext'] ) ); WP_CLI::log( sprintf( ' empty: %d', $counts['empty'] ) ); if ( $counts['v1'] > 0 ) { WP_CLI::warning( sprintf( '%d v1 ciphertext(s) detected. Run `wp wpdo crypto-migrate` to upgrade to v2 GCM.', $counts['v1'] ) ); } else { WP_CLI::success( 'No v1 ciphertext remaining — all encrypted secrets are GCM-authenticated.' ); } } /** * Migrate v1 (AES-256-CBC) ciphertext to v2 (AES-256-GCM) under `wpdo_*` options. * * Idempotent: already-v2, plaintext, and empty options are skipped without error. * * ## OPTIONS * * [--prefix=] * : Option name prefix to scan. Default: wpdo_. * * [--dry-run] * : Show what would change without applying. * * ## EXAMPLES * * wp wpdo crypto-migrate --dry-run * wp wpdo crypto-migrate * * @param array $args Positional arguments (unused). * @param array $assoc_args Named arguments. * @return void */ public function crypto_migrate( $args, $assoc_args ): void { unset( $args ); if ( ! class_exists( 'TMDO_Crypto' ) ) { WP_CLI::error( 'TMDO_Crypto class not loaded.' ); } $prefix = (string) ( $assoc_args['prefix'] ?? 'wpdo_' ); $dry_run = isset( $assoc_args['dry-run'] ); if ( $dry_run ) { global $wpdb; $option_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( $prefix ) . '%' ) ); $v1_count = 0; foreach ( (array) $option_names as $name ) { if ( 'v1' === TMDO_Crypto::format_version( $name ) ) { ++$v1_count; } } WP_CLI::success( sprintf( '[dry-run] %d v1 option(s) would be migrated. Re-run without --dry-run to apply.', $v1_count ) ); return; } $counts = TMDO_Crypto::migrate_v1_to_v2( $prefix ); WP_CLI::log( sprintf( 'Scanned: %d', $counts['scanned'] ) ); WP_CLI::log( sprintf( 'Migrated: %d', $counts['migrated'] ) ); WP_CLI::log( sprintf( 'Already v2: %d', $counts['already_v2'] ) ); WP_CLI::log( sprintf( 'Plaintext: %d', $counts['plaintext'] ) ); WP_CLI::log( sprintf( 'Empty: %d', $counts['empty'] ) ); WP_CLI::log( sprintf( 'Failed: %d', $counts['failed'] ) ); if ( $counts['failed'] > 0 ) { foreach ( $counts['errors'] as $option_name => $reason ) { WP_CLI::warning( sprintf( ' %s → %s', $option_name, $reason ) ); } WP_CLI::error( sprintf( '%d migration(s) failed; see warnings above.', $counts['failed'] ) ); } // Set the migrated flag so installer's auto-migration won't re-scan. update_option( 'wpdo_crypto_migrated_v2', '1', false ); WP_CLI::success( sprintf( 'Migrated %d option(s) from v1 (CBC) to v2 (GCM).', $counts['migrated'] ) ); } } WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) ); WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) ); WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) ); WP_CLI::add_command( 'tmdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) ); WP_CLI::add_command( 'wpdo shadow-enable', array( 'TMDO_CLI_V2', 'shadow_enable' ) ); WP_CLI::add_command( 'tmdo shadow-enable', array( 'TMDO_CLI_V2', 'shadow_enable' ) ); WP_CLI::add_command( 'wpdo shadow-disable', array( 'TMDO_CLI_V2', 'shadow_disable' ) ); WP_CLI::add_command( 'tmdo shadow-disable', array( 'TMDO_CLI_V2', 'shadow_disable' ) ); WP_CLI::add_command( 'wpdo conflict-scan', array( 'TMDO_CLI_V2', 'conflict_scan' ) ); WP_CLI::add_command( 'tmdo conflict-scan', array( 'TMDO_CLI_V2', 'conflict_scan' ) ); WP_CLI::add_command( 'wpdo lint', array( 'TMDO_CLI_V2', 'lint' ) ); WP_CLI::add_command( 'tmdo lint', array( 'TMDO_CLI_V2', 'lint' ) ); WP_CLI::add_command( 'wpdo snapshot create', array( 'TMDO_CLI_V2', 'snapshot_create' ) ); WP_CLI::add_command( 'tmdo snapshot create', array( 'TMDO_CLI_V2', 'snapshot_create' ) ); WP_CLI::add_command( 'wpdo snapshot list', array( 'TMDO_CLI_V2', 'snapshot_list' ) ); WP_CLI::add_command( 'tmdo snapshot list', array( 'TMDO_CLI_V2', 'snapshot_list' ) ); WP_CLI::add_command( 'wpdo snapshot restore', array( 'TMDO_CLI_V2', 'snapshot_restore' ) ); WP_CLI::add_command( 'tmdo snapshot restore', array( 'TMDO_CLI_V2', 'snapshot_restore' ) ); WP_CLI::add_command( 'wpdo snapshot prune', array( 'TMDO_CLI_V2', 'snapshot_prune' ) ); WP_CLI::add_command( 'tmdo snapshot prune', array( 'TMDO_CLI_V2', 'snapshot_prune' ) ); WP_CLI::add_command( 'wpdo snapshot verify', array( 'TMDO_CLI_V2', 'snapshot_verify' ) ); WP_CLI::add_command( 'tmdo snapshot verify', array( 'TMDO_CLI_V2', 'snapshot_verify' ) ); WP_CLI::add_command( 'wpdo crypto-status', array( 'TMDO_CLI_V2', 'crypto_status' ) ); WP_CLI::add_command( 'tmdo crypto-status', array( 'TMDO_CLI_V2', 'crypto_status' ) ); WP_CLI::add_command( 'wpdo crypto-migrate', array( 'TMDO_CLI_V2', 'crypto_migrate' ) ); WP_CLI::add_command( 'tmdo crypto-migrate', array( 'TMDO_CLI_V2', 'crypto_migrate' ) );