chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+561
View File
@@ -0,0 +1,561 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
/**
* TMDO_CLI_Member — Member flat-table CLI subcommands.
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo member-audit — Mode, row counts, shadow diff rate.
* wp wpdo member-backfill — Backfill usermeta → flat table.
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
* wp wpdo member-cutover — Switch user mode to aeav_only.
* wp wpdo member-points-check — Show balance + recent ledger for a user.
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
*
* @package WP_Data_Optimizer
* @since 2.5.5
*/
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 accept ($args, $assoc_args) by contract; many subcommands only need one of them.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* Member flat-table CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_Member {
/**
* Valid group names managed by this CLI.
*
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
* the legacy WP/WC/HP wp_usermeta keys.
*/
private const VALID_GROUPS = array(
'membership',
'activity',
'profile',
'sso',
'core_profile',
'social',
'commerce',
'hp_user',
'admin_prefs',
);
// ── member-audit ─────────────────────────────────────────────────────────
/**
* Show current user bridge mode, flat-table row counts, and shadow diff rate.
*
* ## EXAMPLES
*
* wp wpdo member-audit
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments (unused).
*/
public function member_audit( $args, $assoc_args ): void {
global $wpdb;
$mode = TMDO_Mode_Manager::get( 'user' );
WP_CLI::log( "User bridge mode: {$mode}" );
WP_CLI::log( '' );
$items = array();
foreach ( self::VALID_GROUPS as $group ) {
$table = $wpdb->prefix . 'wpdo_user_' . $group;
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
$rows = $exists
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" )
: -1;
$items[] = array(
'group' => $group,
'table' => $table,
'exists' => $exists ? 'yes' : 'NO',
'rows' => $exists ? $rows : '(table missing)',
);
}
WP_CLI\Utils\format_items( 'table', $items, array( 'group', 'table', 'exists', 'rows' ) );
WP_CLI::log( '' );
// Shadow diff rate.
$shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs';
$shadow_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $shadow_table )
);
if ( $shadow_exists ) {
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
"SELECT COUNT(*) FROM `{$shadow_table}` WHERE entity_type = 'user'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
WP_CLI::log( "Shadow diffs (entity_type=user): {$total}" );
} else {
WP_CLI::log( 'Shadow diffs table: not present.' );
}
}
// ── member-backfill ───────────────────────────────────────────────────────
/**
* Backfill wp_usermeta rows into the specified user flat-table group.
*
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
*
* ## OPTIONS
*
* [--group=<group>]
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
*
* [--batch-size=<n>]
* : Rows per batch (default 500, max 2000).
*
* [--dry-run]
* : Preview without writing.
*
* [--reset]
* : Clear checkpoint and restart from the beginning.
*
* ## EXAMPLES
*
* wp wpdo member-backfill --group=membership --dry-run
* wp wpdo member-backfill --group=membership
* wp wpdo member-backfill --group=sso --reset
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_backfill( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? 'membership' );
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
$dry_run = isset( $assoc_args['dry-run'] );
$reset = isset( $assoc_args['reset'] );
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
}
if ( $reset ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
}
WP_CLI::log( $dry_run ? "[dry-run] Backfill user/{$group} ..." : "Backfill user/{$group} ..." );
$result = TMDO_Entity_Migration_Engine::migrate_group(
'user',
$group,
array(
'batch_size' => $batch_size,
'dry_run' => $dry_run,
'resume' => ! $reset,
)
);
// Pre-flight failure (adapter/group/keys/table missing).
if ( ! empty( $result['error'] ) ) {
WP_CLI::error( $result['error'] );
}
$migrated = (int) ( $result['migrated'] ?? 0 );
$errors = (int) ( $result['errors'] ?? 0 );
$skipped = (int) ( $result['skipped'] ?? 0 );
// Row-level failures: migrate_group() catches Throwables per-entity, increments
// $stats['errors'], and continues. The returned $stats has no 'error' key,
// so without this branch the CLI would silently report success.
if ( $errors > 0 && 0 === $migrated ) {
WP_CLI::error(
sprintf(
'All %d row(s) failed during migration — see error_log for details.',
$errors
)
);
}
if ( $errors > 0 ) {
WP_CLI::warning(
sprintf( '%d row(s) failed during migration — see error_log for details.', $errors )
);
}
WP_CLI::success(
sprintf(
'Migrated %d rows, %d errors, %d skipped (%.2f sec)%s.',
$migrated,
$errors,
$skipped,
$result['elapsed_sec'] ?? 0,
$dry_run ? ' [dry-run, no changes written]' : ''
)
);
}
// ── member-shadow-report ──────────────────────────────────────────────────
/**
* Display recent shadow_diffs rows for entity_type=user.
*
* ## OPTIONS
*
* [--limit=<n>]
* : Max rows to display (default 20).
*
* [--format=<format>]
* : Output format: table, json, csv. Default: table.
*
* ## EXAMPLES
*
* wp wpdo member-shadow-report
* wp wpdo member-shadow-report --limit=50 --format=json
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_shadow_report( $args, $assoc_args ): void {
global $wpdb;
$limit = min( 500, max( 1, (int) ( $assoc_args['limit'] ?? 20 ) ) );
$format = (string) ( $assoc_args['format'] ?? 'table' );
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
WP_CLI::warning( 'wpdo_shadow_diffs table not present — no shadow diffs collected yet.' );
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, entity_id, meta_key, postmeta_value, zone_value, diff_hash, ts FROM `{$table}` WHERE entity_type = 'user' ORDER BY ts DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$limit
),
ARRAY_A
);
if ( empty( $rows ) ) {
WP_CLI::success( 'No shadow diffs found for entity_type=user.' );
return;
}
WP_CLI::log( count( $rows ) . " diff(s) found (limit {$limit}):" );
WP_CLI\Utils\format_items(
$format,
$rows,
array( 'id', 'entity_id', 'meta_key', 'diff_hash', 'ts' )
);
}
// ── member-cutover ────────────────────────────────────────────────────────
/**
* Switch the user bridge mode to aeav_only (reads and writes go to flat table only).
*
* Requires --confirm to prevent accidental invocation.
* Recommended only after shadow diff rate drops below 0.1%.
*
* ## OPTIONS
*
* [--confirm]
* : Required confirmation flag.
*
* ## EXAMPLES
*
* wp wpdo member-cutover --confirm
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_cutover( $args, $assoc_args ): void {
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'You must pass --confirm to cut over. Check shadow diff rate first: wp wpdo member-shadow-report' );
}
$current = TMDO_Mode_Manager::get( 'user' );
WP_CLI::log( "Current user mode: {$current}" );
if ( 'aeav_only' === $current ) {
WP_CLI::success( 'User mode is already aeav_only. Nothing to do.' );
return;
}
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
if ( isset( $result['error'] ) ) {
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
}
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
}
// ── member-points-check ───────────────────────────────────────────────────
/**
* Show points balance and recent ledger entries for a user.
*
* ## OPTIONS
*
* <user_id>
* : WordPress user ID.
*
* [--limit=<n>]
* : Ledger rows to show (default 10).
*
* ## EXAMPLES
*
* wp wpdo member-points-check 42
* wp wpdo member-points-check 42 --limit=20
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_points_check( $args, $assoc_args ): void {
$user_id = (int) ( $args[0] ?? 0 );
if ( $user_id <= 0 ) {
WP_CLI::error( 'Usage: wp wpdo member-points-check <user_id>' );
}
$limit = min( 100, max( 1, (int) ( $assoc_args['limit'] ?? 10 ) ) );
$balance = TMDO_Points_Manager::get_balance( $user_id );
WP_CLI::log( "User {$user_id} — points balance: {$balance}" );
WP_CLI::log( '' );
$ledger = TMDO_Points_Manager::get_ledger( $user_id, $limit );
if ( empty( $ledger ) ) {
WP_CLI::log( 'No ledger entries found.' );
return;
}
WP_CLI\Utils\format_items(
'table',
$ledger,
array( 'id', 'delta', 'balance_after', 'reason', 'ref_type', 'ref_id', 'created_at' )
);
}
// ── member-sso-sync ───────────────────────────────────────────────────────
/**
* Sync Hub membership claims into the user flat table.
*
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
* from usermeta and writes them into the sso group flat table.
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
* directly to the sso group via the Hook Bus.
*
* ## OPTIONS
*
* [--user-id=<id>]
* : Single user ID to sync.
*
* [--all]
* : Sync all users that have _tmso_refresh_token in usermeta (batch 500).
*
* [--dry-run]
* : Preview without writing.
*
* ## EXAMPLES
*
* wp wpdo member-sso-sync --user-id=42
* wp wpdo member-sso-sync --all --dry-run
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_sso_sync( $args, $assoc_args ): void {
global $wpdb;
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
$all = isset( $assoc_args['all'] );
$dry_run = isset( $assoc_args['dry-run'] );
if ( ! $user_id && ! $all ) {
WP_CLI::error( 'Provide --user-id=<id> or --all.' );
}
$sso_table = $wpdb->prefix . 'wpdo_user_sso';
$table_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $sso_table )
);
if ( ! $table_exists ) {
WP_CLI::error( "SSO flat table {$sso_table} does not exist. Run `wp wpdo doctor` first." );
}
$user_ids = array();
if ( $user_id ) {
$user_ids = array( $user_id );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$user_ids = $wpdb->get_col(
"SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = '_tmso_refresh_token' LIMIT 5000" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
}
if ( empty( $user_ids ) ) {
WP_CLI::log( 'No users with _tmso_refresh_token found.' );
return;
}
$synced = 0;
$skipped = 0;
$sso_keys = array(
'hub_global_user_id' => '_tmso_global_user_id',
'picture_url' => '_tmso_picture_url',
'refresh_token_enc' => '_tmso_refresh_token',
);
foreach ( $user_ids as $uid ) {
$uid = (int) $uid;
$data = array( 'user_id' => $uid );
foreach ( $sso_keys as $flat_col => $meta_key ) {
$val = get_user_meta( $uid, $meta_key, true );
if ( '' !== $val ) {
// Reject plaintext refresh tokens — must carry the enc_vN: envelope
// written by TMSO_Crypto::encrypt(). Storing cleartext here would
// silently bypass the encryption layer.
if ( 'refresh_token_enc' === $flat_col && ! preg_match( '/^enc_v\d+:/', (string) $val ) ) {
TMDO_Logger::error( 'sso_sync', 'plaintext_token_rejected', "user_id={$uid} meta_key={$meta_key}" );
continue;
}
$data[ $flat_col ] = $val;
}
}
if ( count( $data ) <= 1 ) {
++$skipped;
continue;
}
if ( ! $dry_run ) {
$wpdb->replace( $sso_table, $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
}
++$synced;
}
WP_CLI::success(
sprintf(
'SSO sync complete — synced: %d, skipped (no meta): %d%s.',
$synced,
$skipped,
$dry_run ? ' [dry-run]' : ''
)
);
}
// ── member-force-logout ───────────────────────────────────────────────────
/**
* Force re-authentication by setting token_expires_at to a past datetime.
*
* The Spoke silent-refresh logic detects an expired token_expires_at and
* redirects the user to re-authenticate against the Hub. If the Hub has
* the account blocked, the user is logged out from all Spokes.
*
* ## OPTIONS
*
* [--user-id=<id>]
* : WordPress local user ID to force-logout.
*
* [--global-user-id=<uuid>]
* : Hub global_user_id to force-logout (looks up via hub_global_user_id column).
*
* [--confirm]
* : Required safety flag — confirms intent to invalidate live SSO tokens.
*
* ## EXAMPLES
*
* wp wpdo member-force-logout --user-id=42 --confirm
* wp wpdo member-force-logout --global-user-id=abc-123 --confirm
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_force_logout( $args, $assoc_args ): void {
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
}
global $wpdb;
$sso_table = $wpdb->prefix . 'wpdo_user_sso';
$past = '2000-01-01 00:00:00';
if ( isset( $assoc_args['user-id'] ) ) {
$uid = (int) $assoc_args['user-id'];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$sso_table}` SET token_expires_at = %s WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$past,
$uid
)
);
if ( $updated ) {
TMDO_Logger::info(
'member_force_logout',
array(
'user_id' => $uid,
'via' => 'user-id',
)
);
WP_CLI::success( "Force-logout applied to user_id={$uid}. Token invalidated." );
} else {
WP_CLI::warning( "No SSO row found for user_id={$uid}." );
}
return;
}
if ( isset( $assoc_args['global-user-id'] ) ) {
$global_id = sanitize_text_field( (string) $assoc_args['global-user-id'] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$sso_table}` SET token_expires_at = %s WHERE hub_global_user_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$past,
$global_id
)
);
if ( $updated ) {
TMDO_Logger::info(
'member_force_logout',
array(
'global_user_id' => $global_id,
'affected_rows' => $updated,
'via' => 'global-user-id',
)
);
WP_CLI::success( "Force-logout applied to global_user_id={$global_id}. Affected rows: {$updated}." );
} else {
WP_CLI::warning( "No SSO row found for global_user_id={$global_id}." );
}
return;
}
WP_CLI::error( 'Provide --user-id=<id> or --global-user-id=<uuid>.' );
}
}
// ── Register subcommands ──────────────────────────────────────────────────────
WP_CLI::add_command( 'wpdo member-audit', array( 'TMDO_CLI_Member', 'member_audit' ) );
WP_CLI::add_command( 'wpdo member-backfill', array( 'TMDO_CLI_Member', 'member_backfill' ) );
WP_CLI::add_command( 'wpdo member-shadow-report', array( 'TMDO_CLI_Member', 'member_shadow_report' ) );
WP_CLI::add_command( 'wpdo member-cutover', array( 'TMDO_CLI_Member', 'member_cutover' ) );
WP_CLI::add_command( 'wpdo member-points-check', array( 'TMDO_CLI_Member', 'member_points_check' ) );
WP_CLI::add_command( 'wpdo member-sso-sync', array( 'TMDO_CLI_Member', 'member_sso_sync' ) );
WP_CLI::add_command( 'wpdo member-force-logout', array( 'TMDO_CLI_Member', 'member_force_logout' ) );
+844
View File
@@ -0,0 +1,844 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
/**
* TMDO_CLI_Post — Post entity CLI subcommands (v2.9.0+).
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo postmeta-cleanup — Clean wp_postmeta garbage (transients,
* _wp_old_date, stale _edit_lock).
*
* Mirrors TMDO_CLI_Member's design: thin wrapper around core class
* (TMDO_Postmeta_Cleaner). Future post-entity subcommands (post-audit,
* post-backfill, post-cutover) will live in this same file.
*
* @package WP_Data_Optimizer
* @since 2.9.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* Post entity CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_Post {
// ── postmeta-cleanup ──────────────────────────────────────────────────────
/**
* Clean wp_postmeta garbage rows (transients, _wp_old_date, stale _edit_lock).
*
* Phase 0 of v2.9.0 Post Entity migration. Runs before any Entity Bridge
* work so subsequent ratio measurements reflect real data, not garbage.
*
* SAFETY: by default this command refuses to run. Pass --dry-run to
* preview row counts without deleting, or --confirm to actually delete.
*
* ## OPTIONS
*
* [--target=<target>]
* : Which garbage class to address. Default: all.
* ---
* default: all
* options:
* - all
* - transients
* - wp_old_date
* - edit_locks
* ---
*
* [--dry-run]
* : Show row counts without deleting.
*
* [--confirm]
* : Required to actually DELETE rows. Mutually exclusive with --dry-run.
*
* ## EXAMPLES
*
* wp wpdo postmeta-cleanup --dry-run
* wp wpdo postmeta-cleanup --target=transients --dry-run
* wp wpdo postmeta-cleanup --confirm
* wp wpdo postmeta-cleanup --target=edit_locks --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function postmeta_cleanup( $args, $assoc_args ): void {
$target = (string) ( $assoc_args['target'] ?? TMDO_Postmeta_Cleaner::TARGET_ALL );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( ! in_array( $target, TMDO_Postmeta_Cleaner::VALID_TARGETS, true ) ) {
WP_CLI::error(
'Invalid --target. Choose: ' . implode( ', ', TMDO_Postmeta_Cleaner::VALID_TARGETS )
);
}
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
// Default safety: refuse to run without an explicit choice.
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo postmeta-cleanup --dry-run'
);
}
if ( $dry_run ) {
$counts = TMDO_Postmeta_Cleaner::count_garbage( $target );
self::render_table( $counts, 'would delete' );
WP_CLI::success(
sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] )
);
return;
}
// $confirm path.
$deleted = TMDO_Postmeta_Cleaner::delete_garbage( $target );
self::render_table( $deleted, 'deleted' );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'postmeta_cleanup',
array(
'target' => $target,
'transients' => $deleted['transients'],
'wp_old_date' => $deleted['wp_old_date'],
'edit_locks' => $deleted['edit_locks'],
'total' => $deleted['total'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_postmeta.', $deleted['total'] ) );
}
/**
* Render a table of bucket → count.
*
* @param array $counts Output from TMDO_Postmeta_Cleaner::count_garbage() / delete_garbage().
* @param string $verb Column header verb ('would delete' / 'deleted').
*/
private static function render_table( array $counts, string $verb ): void {
$rows = array(
array(
'bucket' => 'transients',
$verb => $counts['transients'],
'rule' => '_transient_% OR _transient_timeout_%',
),
array(
'bucket' => 'wp_old_date',
$verb => $counts['wp_old_date'],
'rule' => "meta_key = '_wp_old_date'",
),
array(
'bucket' => 'edit_locks',
$verb => $counts['edit_locks'],
'rule' => '_edit_lock older than 24h',
),
array(
'bucket' => 'TOTAL',
$verb => $counts['total'],
'rule' => '',
),
);
WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
}
// ── post-diagnose (v2.9.3) ────────────────────────────────────────────────
/**
* Show wp_posts:wp_postmeta ratio + per-group EAV residue / flat row count.
*
* Read-only command. Mirrors `wp wpdo member-audit` for the post entity.
*
* ## EXAMPLES
*
* wp wpdo post-diagnose
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments (unused).
*/
public function post_diagnose( $args, $assoc_args ): void {
$result = TMDO_Post_Migration::diagnose();
WP_CLI::log( sprintf( 'Posts: %s', number_format_i18n( $result['posts'] ) ) );
WP_CLI::log( sprintf( 'Postmeta: %s', number_format_i18n( $result['postmeta'] ) ) );
WP_CLI::log( sprintf( 'Ratio: 1:%s', $result['ratio'] ) );
WP_CLI::log( sprintf( 'Mode: %s', $result['mode'] ) );
WP_CLI::log( '' );
$rows = array();
foreach ( $result['groups'] as $name => $g ) {
$rows[] = array(
'group' => $name,
'post_type' => $g['post_type'] ?: '(any)',
'keys' => count( $g['keys'] ),
'eav_rows' => $g['eav_rows'],
'flat_rows' => $g['flat_rows'],
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'post_type', 'keys', 'eav_rows', 'flat_rows' ) );
}
// ── post-migrate-group (v2.9.3) ───────────────────────────────────────────
/**
* Backfill one post entity group from wp_postmeta into its flat table
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
*
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
* row-by-row backfill phase in v2.9.4+).
*
* ## OPTIONS
*
* --group=<group>
* : Entity group name. Required.
* ---
* options:
* - wp_core
* - attachment
* - wc_product
* - hp_listing_core
* - hp_request_core
* - hp_vendor_core
* - nav_menu_item
* ---
*
* ## EXAMPLES
*
* wp wpdo post-migrate-group --group=wc_product
* wp wpdo post-migrate-group --group=hp_listing_core
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_migrate_group( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? '' );
if ( '' === $group ) {
WP_CLI::error( 'Missing required --group=<name>.' );
}
try {
$result = TMDO_Post_Migration::backfill_group( $group );
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_migrate_group',
array(
'group' => $result['group'],
'post_type' => $result['post_type'],
'migrated' => $result['migrated'],
)
);
}
WP_CLI::success(
sprintf(
'Backfilled %s (post_type=%s): %d post(s) migrated.',
$result['group'],
$result['post_type'] ?: '(any)',
$result['migrated']
)
);
}
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
/**
* DELETE managed-key wp_postmeta rows after cutover. Requires post mode
* to be aeav_only (the flat table is the authoritative source).
*
* SAFETY: requires --confirm.
*
* ## OPTIONS
*
* [--dry-run]
* : Preview row count only.
*
* [--confirm]
* : Required to actually DELETE rows.
*
* ## EXAMPLES
*
* wp wpdo post-cleanup --dry-run
* wp wpdo post-cleanup --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_cleanup( $args, $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo post-cleanup --dry-run'
);
}
$diagnose = TMDO_Post_Migration::diagnose();
$keys = TMDO_Post_Migration::get_managed_keys();
global $wpdb;
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$sql = "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$candidate = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$keys ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
if ( $dry_run ) {
WP_CLI::log( sprintf( 'Mode: %s', $diagnose['mode'] ) );
WP_CLI::log( sprintf( 'Managed keys: %d', count( $keys ) ) );
WP_CLI::success(
sprintf(
'[dry-run] %d wp_postmeta row(s) would be deleted. Re-run with --confirm.',
$candidate
)
);
return;
}
// $confirm path — TMDO_Post_Migration::cleanup() enforces mode=aeav_only.
try {
$result = TMDO_Post_Migration::cleanup();
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_cleanup',
array(
'deleted' => $result['deleted'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d wp_postmeta row(s).', $result['deleted'] ) );
}
// ── cleanup-hp-transients (v2.11.5) ───────────────────────────────────────
/**
* Purge legacy `_transient_hp_*` rows from wp_postmeta.
*
* HivePress (`hivepress/includes/components/class-cache.php`) writes
* per-post TTL caches as `_transient_<name>` / `_transient_timeout_<name>`
* postmeta rows. v2.11.5 ships `TMDO_Hivepress_Transient_Filter` to
* intercept new writes and reroute them to wp_options. This command does
* the one-time historical cleanup — DELETE-ing all such existing rows
* from wp_postmeta. After cleanup, HivePress re-fetches on demand.
*
* Safe to run with the filter enabled; the filter prevents new postmeta
* writes from re-bloating the table.
*
* ## OPTIONS
*
* [--dry-run]
* : Preview row count only.
*
* [--confirm]
* : Required to actually DELETE rows.
*
* ## EXAMPLES
*
* wp wpdo cleanup-hp-transients --dry-run
* wp wpdo cleanup-hp-transients --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function cleanup_hp_transients( $args, $assoc_args ): void {
unset( $args );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without explicit choice. Pass --dry-run or --confirm.\n" .
'Example: wp wpdo cleanup-hp-transients --dry-run'
);
}
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
}
$count = TMDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows();
if ( $dry_run ) {
WP_CLI::success(
sprintf(
'[dry-run] %d HivePress transient row(s) in wp_postmeta would be deleted. Re-run with --confirm.',
$count
)
);
return;
}
$deleted = TMDO_Hivepress_Transient_Filter::purge_legacy_postmeta_rows();
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'cleanup_hp_transients',
array( 'deleted' => $deleted )
);
}
WP_CLI::success(
sprintf(
'Deleted %d HivePress transient row(s) from wp_postmeta. Future writes auto-route to wp_options.',
$deleted
)
);
}
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
/**
* Non-destructive copy of a legacy `wpdo_hot_<post_type>` zone table
* into the new `wp_wpdo_post_<group>` flat table.
*
* The legacy table is left UNTOUCHED (safety net for v3.0.0 rollback).
* Idempotent — re-running is safe.
*
* Currently supports hp_listing only (the only post_type with a
* legacy hot table on production deployments). Other post_types skip
* the cutover and rely on direct wp_postmeta backfill.
*
* ## OPTIONS
*
* --post-type=<post_type>
* : Post type to cutover.
* ---
* default: hp_listing
* options:
* - hp_listing
* ---
*
* [--dry-run]
* : Preview row counts only.
*
* [--confirm]
* : Required to actually run the copy.
*
* ## EXAMPLES
*
* wp wpdo post-cutover-legacy --dry-run
* wp wpdo post-cutover-legacy --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_cutover_legacy( $args, $assoc_args ): void {
$post_type = (string) ( $assoc_args['post-type'] ?? 'hp_listing' );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to run.\n" .
'Example: wp wpdo post-cutover-legacy --dry-run'
);
}
// Map post_type → (hot_table, flat_table, group).
$mapping = array(
'hp_listing' => array(
'hot' => 'wpdo_hot_hp_listing',
'flat' => 'wpdo_post_hp_listing_core',
'group' => 'hp_listing_core',
),
);
if ( ! isset( $mapping[ $post_type ] ) ) {
WP_CLI::error( 'Unsupported --post-type: ' . $post_type );
}
global $wpdb;
$hot_table = $wpdb->prefix . $mapping[ $post_type ]['hot'];
$flat_table = $wpdb->prefix . $mapping[ $post_type ]['flat'];
// Pre-flight: verify hot table exists and report current state.
$hot_exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $hot_table )
);
if ( ! $hot_exists ) {
WP_CLI::warning( "Legacy hot table not present: {$hot_table}. Nothing to do." );
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
WP_CLI::log( sprintf( 'Hot (%s): %s rows', $hot_table, number_format_i18n( $hot_rows ) ) );
WP_CLI::log( sprintf( 'Flat (%s): %s rows', $flat_table, number_format_i18n( $flat_rows ) ) );
WP_CLI::log( '' );
if ( $dry_run ) {
WP_CLI::success(
sprintf(
'[dry-run] Would copy %s rows from hot → flat. Re-run with --confirm to apply.',
number_format_i18n( $hot_rows )
)
);
return;
}
// $confirm path.
try {
$result = TMDO_Post_Migration::copy_legacy_hot_table( $post_type, $hot_table, $flat_table );
$verify = TMDO_Post_Migration::verify_legacy_cutover( $hot_table, $flat_table );
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_cutover_legacy',
array(
'post_type' => $post_type,
'hot_table' => $hot_table,
'flat_table' => $flat_table,
'copied' => $result['copied'],
'common_columns' => $result['common_columns'],
'verify' => $verify,
)
);
}
WP_CLI::log( sprintf( 'Copied %d row(s). Common columns: %s', $result['copied'], implode( ', ', $result['common_columns'] ) ) );
WP_CLI::log(
sprintf(
'Verify: hot=%d flat=%d mismatched=%d ok=%s',
$verify['hot_rows'],
$verify['flat_rows'],
$verify['mismatched_rows'],
$verify['ok'] ? 'yes' : 'NO'
)
);
if ( ! $verify['ok'] ) {
WP_CLI::error( 'Verification failed — flat table missing rows. Legacy hot table left intact for retry.' );
}
WP_CLI::success(
sprintf(
'Legacy cutover complete (%s). Hot table preserved as safety net for v3.0.0 rollback.',
$post_type
)
);
}
// ── post-benchmark (v2.10.2) ──────────────────────────────────────────────
/**
* Compare query latency between wp_postmeta JOIN and flat-table JOIN
* for a single meta_key/value combination.
*
* Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
*
* Read-only — does not modify any table or change post mode. Safe to run
* in any post mode (the benchmark always queries both paths regardless).
*
* ## OPTIONS
*
* --post-type=<post_type>
* : Post type to filter by (e.g. product, hp_listing).
*
* --meta-key=<key>
* : Meta key to query (must be registered in entity registry for $post_type).
*
* [--compare=<op>]
* : Comparison operator. Default: =.
* ---
* default: =
* options:
* - "="
* - "!="
* - "<"
* - "<="
* - ">"
* - ">="
* - "LIKE"
* ---
*
* --value=<value>
* : Value to compare against.
*
* [--samples=<n>]
* : Number of times to run each query. Default: 50.
*
* ## EXAMPLES
*
* wp wpdo post-benchmark --post-type=hp_listing --meta-key=hp_price --compare=">=" --value=100
* wp wpdo post-benchmark --post-type=product --meta-key=_price --compare="=" --value=99 --samples=200
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_benchmark( $args, $assoc_args ): void {
$post_type = (string) ( $assoc_args['post-type'] ?? '' );
$meta_key = (string) ( $assoc_args['meta-key'] ?? '' );
$compare = (string) ( $assoc_args['compare'] ?? '=' );
$value = (string) ( $assoc_args['value'] ?? '' );
$samples = max( 1, (int) ( $assoc_args['samples'] ?? 50 ) );
if ( '' === $post_type || '' === $meta_key ) {
WP_CLI::error( 'Both --post-type and --meta-key are required.' );
}
// Look up the entity group for this meta_key to find the flat table.
$field = TMDO_Entity_Registry::get_field( 'post', $meta_key );
if ( ! $field ) {
WP_CLI::error( "Meta key '{$meta_key}' is not registered for entity_type='post'." );
}
global $wpdb;
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $field['group'] );
try {
$result = TMDO_Post_Migration::benchmark_query(
$post_type,
$meta_key,
$compare,
$value,
$flat_table,
$samples
);
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
WP_CLI::log(
sprintf(
'Benchmark: %s.%s %s %s (samples=%d)',
$post_type,
$meta_key,
$compare,
$value,
$samples
)
);
WP_CLI::log( '' );
WP_CLI::log(
sprintf(
' postmeta JOIN: %.3f ms avg (matched %d rows)',
$result['postmeta_avg_ms'],
$result['postmeta_rows']
)
);
WP_CLI::log(
sprintf(
' flat JOIN: %.3f ms avg (matched %d rows)',
$result['flat_avg_ms'],
$result['flat_rows']
)
);
WP_CLI::log( '' );
WP_CLI::log( sprintf( ' → Speedup: %.2fx', $result['speedup'] ) );
// Persist to wpdo_benchmarks table.
$bench_table = $wpdb->prefix . 'wpdo_benchmarks';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->insert(
$bench_table,
array(
'module' => 'post_router_' . $post_type,
'zone' => 'flat',
'query_type' => 'meta_query_' . $meta_key,
'native_ms' => $result['postmeta_avg_ms'],
'custom_ms' => $result['flat_avg_ms'],
'sample_size' => $samples,
'created_at' => gmdate( 'Y-m-d H:i:s' ),
),
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
);
WP_CLI::success( sprintf( 'Benchmark recorded to %s.', $bench_table ) );
}
// ── post-shadow-report (v2.10.3) ──────────────────────────────────────────
/**
* Show shadow_read divergence stats for entity_type='post'.
*
* When post mode is shadow_read, the TMDO_Post_Shadow_Verifier cron job
* runs hourly and writes any flat vs wp_postmeta divergences to
* wpdo_shadow_diffs. This command surfaces a 24h aggregate.
*
* Optionally runs an immediate sample-compare pass via --run-now.
*
* ## OPTIONS
*
* [--hours=<n>]
* : Aggregation window in hours. Default: 24.
*
* [--limit=<n>]
* : Max recent diff rows to display. Default: 10.
*
* [--run-now]
* : Execute one sample-compare tick immediately (alongside the report).
*
* ## EXAMPLES
*
* wp wpdo post-shadow-report
* wp wpdo post-shadow-report --hours=48 --limit=20
* wp wpdo post-shadow-report --run-now
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_shadow_report( $args, $assoc_args ): void {
$hours = max( 1, (int) ( $assoc_args['hours'] ?? 24 ) );
$limit = max( 1, (int) ( $assoc_args['limit'] ?? 10 ) );
$run_now = isset( $assoc_args['run-now'] );
$mode = TMDO_Mode_Manager::get( 'post' );
WP_CLI::log( sprintf( 'Post mode: %s', $mode ) );
WP_CLI::log( sprintf( 'Window: last %d hours', $hours ) );
WP_CLI::log( '' );
if ( $run_now ) {
WP_CLI::log( 'Running sample-compare for all groups...' );
$totals = array(
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_postmeta' => 0,
);
global $wpdb;
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$keys = TMDO_Entity_Registry::get_group_keys( 'post', $group );
if ( empty( $keys ) ) {
continue;
}
// Use the verifier's own group→post_type mapping reflectively.
$pt = self::group_post_type_for_cli( $group );
if ( null === $pt ) {
continue;
}
$flat = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
try {
$res = TMDO_Post_Shadow_Verifier::sample_compare( $pt, $group, $flat, $keys, 50 );
foreach ( $totals as $k => $_ ) {
$totals[ $k ] += (int) ( $res[ $k ] ?? 0 );
}
WP_CLI::log(
sprintf(
' %-20s sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
$group,
$res['sampled'],
$res['matched'],
$res['diffs'],
$res['missing_flat'],
$res['missing_postmeta']
)
);
} catch ( \Throwable $e ) {
WP_CLI::warning( " {$group}: " . $e->getMessage() );
}
}
WP_CLI::log( '' );
WP_CLI::log(
sprintf(
'Run-now totals: sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
$totals['sampled'],
$totals['matched'],
$totals['diffs'],
$totals['missing_flat'],
$totals['missing_postmeta']
)
);
WP_CLI::log( '' );
}
// 24h aggregate from wpdo_shadow_diffs.
$stats = TMDO_Post_Shadow_Verifier::diff_stats( $hours );
WP_CLI::log( sprintf( 'Total diffs (last %dh): %d', $hours, $stats['total'] ) );
if ( ! empty( $stats['by_key'] ) ) {
WP_CLI::log( '' );
$rows = array();
foreach ( $stats['by_key'] as $key => $count ) {
$rows[] = array(
'meta_key' => $key,
'diffs' => $count,
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'meta_key', 'diffs' ) );
}
// Recent diff rows.
if ( $stats['total'] > 0 ) {
$recent = TMDO_Post_Shadow_Verifier::recent_diffs( $limit );
if ( ! empty( $recent ) ) {
WP_CLI::log( '' );
WP_CLI::log( sprintf( 'Recent %d diff(s):', $limit ) );
WP_CLI\Utils\format_items( 'table', $recent, array( 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'ts' ) );
}
}
WP_CLI::success( 'Shadow report complete.' );
}
/**
* Helper: map entity group to its primary post_type for the run-now CLI path.
* Mirror of TMDO_Post_Shadow_Verifier's private mapping.
*
* @param string $group Entity group name.
* @return string|null
*/
private static function group_post_type_for_cli( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
}
}
// ── Register subcommands ──────────────────────────────────────────────────────
WP_CLI::add_command( 'wpdo postmeta-cleanup', array( 'TMDO_CLI_Post', 'postmeta_cleanup' ) );
WP_CLI::add_command( 'wpdo post-diagnose', array( 'TMDO_CLI_Post', 'post_diagnose' ) );
WP_CLI::add_command( 'wpdo post-migrate-group', array( 'TMDO_CLI_Post', 'post_migrate_group' ) );
WP_CLI::add_command( 'wpdo post-cleanup', array( 'TMDO_CLI_Post', 'post_cleanup' ) );
WP_CLI::add_command( 'wpdo post-cutover-legacy', array( 'TMDO_CLI_Post', 'post_cutover_legacy' ) );
WP_CLI::add_command( 'wpdo cleanup-hp-transients', array( 'TMDO_CLI_Post', 'cleanup_hp_transients' ) );
WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
File diff suppressed because it is too large Load Diff
+795
View File
@@ -0,0 +1,795 @@
<?php
/**
* TMDO_CLI_V2 — v2.0.0 CLI subcommands.
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo bridge-status — Hook Bus enabled? Conflict snapshot.
* wp wpdo bridge-set <on|off> — Toggle TMDO_Hook_Bus_Bridge.
* wp wpdo mode-audit — Per-module state + shadow flags.
* wp wpdo mode-set <module> <state> — Set FSM state.
* wp wpdo shadow-enable <module> — Enable shadow_read_only sub-flag.
* wp wpdo shadow-disable <module> — Disable shadow_read_only.
* wp wpdo conflict-scan — Run conflict monitor + emit JSON.
* wp wpdo lint --plugin=<path> — 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=<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
*
* <state>
* : 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 <on|off>' );
}
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>
* : Module name (e.g. hot_hp_listing)
*
* <state>
* : 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 <module> <state> — 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 <module>' );
}
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 <module>' );
}
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=<path>
* : Absolute path to plugin directory.
*
* [--strict]
* : Exit non-zero on any finding.
*
* [--max-autoload=<n>]
* : 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=<path> 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<int, array{rule:string, file:string, line:int, message:string}>
*/
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.<rule> 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=<trigger>]
* : One of manual|pre_fsm_transition|pre_v2_upgrade|scheduled|pre_uninstall.
* ---
* default: manual
* ---
*
* [--scope-tables=<csv>]
* : Comma-separated table list to dump. Empty = all WPDO tables.
*
* [--scope-entities=<csv>]
* : Comma-separated entity list (post,user,term,comment) — adds wp_*meta to dump.
*
* [--notes=<text>]
* : Free-form note for the catalog.
*
* [--retention-days=<n>]
* : 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=<trigger>]
* : Filter by trigger type.
*
* [--limit=<n>]
* : Default 50.
*
* [--format=<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_id>
* : 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 <snapshot_id> [--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=<n>]
* : Older-than threshold (informational; actual TTL stored in row).
* ---
* default: 30
* ---
*
* [--size-cap-mb=<n>]
* : 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_id>
* : 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 <snapshot_id>' );
}
$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<int,string>
*/
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=<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=<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( 'wpdo 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( 'wpdo 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( 'wpdo 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( 'wpdo lint', array( 'TMDO_CLI_V2', 'lint' ) );
WP_CLI::add_command( 'wpdo 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( 'wpdo 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( 'wpdo 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( 'wpdo crypto-migrate', array( 'TMDO_CLI_V2', 'crypto_migrate' ) );
File diff suppressed because it is too large Load Diff