d36bb954d1
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
796 lines
26 KiB
PHP
796 lines
26 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
|
|
*/
|
|
|
|
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' ) );
|