Files
2meet-data-optimizer/cli/class-tmdo-cli-v2.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

813 lines
28 KiB
PHP

<?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
*/
declare(strict_types=1);
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( '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' ) );